mirror of https://gitlab.com/gitlab-org/cli.git
486 lines
12 KiB
Go
486 lines
12 KiB
Go
package view
|
|
|
|
import (
|
|
"fmt"
|
|
"log"
|
|
"runtime/debug"
|
|
"strings"
|
|
|
|
"github.com/AlecAivazis/survey/v2"
|
|
"github.com/gdamore/tcell/v2"
|
|
"github.com/rivo/tview"
|
|
"github.com/spf13/cobra"
|
|
"github.com/xanzy/go-gitlab"
|
|
"gitlab.com/gitlab-org/cli/api"
|
|
"gitlab.com/gitlab-org/cli/commands/cmdutils"
|
|
"gitlab.com/gitlab-org/cli/internal/glrepo"
|
|
|
|
"golang.org/x/text/cases"
|
|
"golang.org/x/text/language"
|
|
)
|
|
|
|
var (
|
|
apiClient *gitlab.Client
|
|
project *gitlab.Project
|
|
repo glrepo.Interface
|
|
)
|
|
|
|
const (
|
|
closed string = "closed"
|
|
opened string = "opened"
|
|
)
|
|
|
|
type issueBoardViewOptions struct {
|
|
assignee string
|
|
labels []string
|
|
milestone string
|
|
state string
|
|
}
|
|
|
|
type boardMeta struct {
|
|
name string
|
|
id int
|
|
group *gitlab.Group
|
|
project *gitlab.Project
|
|
}
|
|
|
|
func NewCmdView(f *cmdutils.Factory) *cobra.Command {
|
|
opts := &issueBoardViewOptions{}
|
|
viewCmd := &cobra.Command{
|
|
Use: "view [flags]",
|
|
Short: `View project issue board.`,
|
|
Long: ``,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
var err error
|
|
|
|
a := tview.NewApplication()
|
|
defer recoverPanic(a)
|
|
|
|
apiClient, err = f.HttpClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
repo, err = f.BaseRepo()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
project, err = api.GetProject(apiClient, repo.FullName())
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get project: %w", err)
|
|
}
|
|
|
|
// list the groups that are ancestors to project:
|
|
// https://docs.gitlab.com/ee/api/projects.html#list-a-projects-groups
|
|
projectGroups, err := api.ListProjectsGroups(
|
|
apiClient,
|
|
project.ID,
|
|
&gitlab.ListProjectGroupOptions{},
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// get issue boards related to project and parent groups
|
|
// https://docs.gitlab.com/ee/api/group_boards.html#list-all-group-issue-boards-in-a-group
|
|
projectIssueBoards, err := getProjectIssueBoards()
|
|
if err != nil {
|
|
return fmt.Errorf("getting project issue boards: %w", err)
|
|
}
|
|
|
|
projectGroupIssueBoards, err := getGroupIssueBoards(projectGroups, apiClient)
|
|
if err != nil {
|
|
return fmt.Errorf("getting project issue boards: %w", err)
|
|
}
|
|
|
|
// prompt user to select issue board
|
|
menuOptions, boardMetaMap := mapBoardData(projectIssueBoards, projectGroupIssueBoards)
|
|
selection, err := selectBoard(menuOptions)
|
|
if err != nil {
|
|
return fmt.Errorf("selecting issue board: %w", err)
|
|
}
|
|
selectedBoard := boardMetaMap[selection]
|
|
|
|
boardLists, err := getBoardLists(selectedBoard)
|
|
if err != nil {
|
|
return fmt.Errorf("getting issue board lists: %w", err)
|
|
}
|
|
|
|
root := tview.NewFlex()
|
|
for _, l := range boardLists {
|
|
opts.state = ""
|
|
var boardIssues, listTitle, listColor string
|
|
|
|
if l.Label == nil {
|
|
continue
|
|
}
|
|
|
|
if l.Label != nil {
|
|
listTitle = l.Label.Name
|
|
listColor = l.Label.Color
|
|
}
|
|
|
|
// automatically request using state for default "open" and "closed" lists
|
|
// this is required as these lists aren't returned with the board lists api call
|
|
switch l.Label.Name {
|
|
case "Closed":
|
|
opts.state = closed
|
|
case "Open":
|
|
opts.state = opened
|
|
}
|
|
|
|
issues := []*gitlab.Issue{}
|
|
if selectedBoard.group != nil {
|
|
groupID := selectedBoard.group.ID
|
|
issues, err = getGroupBoardIssues(groupID, opts)
|
|
if err != nil {
|
|
return fmt.Errorf("getting issue board lists: %w", err)
|
|
}
|
|
}
|
|
|
|
if selectedBoard.group == nil {
|
|
issues, err = getProjectBoardIssues(opts)
|
|
if err != nil {
|
|
return fmt.Errorf("getting issue board lists: %w", err)
|
|
}
|
|
}
|
|
|
|
boardIssues = filterIssues(boardLists, issues, l, opts)
|
|
bx := tview.NewTextView().SetDynamicColors(true)
|
|
bx.SetText(boardIssues).SetWrap(true)
|
|
bx.SetBorder(true).SetTitle(listTitle).SetTitleColor(tcell.GetColor(listColor))
|
|
root.AddItem(bx, 0, 1, false)
|
|
}
|
|
|
|
// format table title
|
|
caser := cases.Title(language.English)
|
|
var boardType, boardContext string
|
|
if selectedBoard.group != nil {
|
|
boardType = caser.String("group")
|
|
boardContext = project.Namespace.Name
|
|
} else {
|
|
boardType = caser.String("project")
|
|
boardContext = project.NameWithNamespace
|
|
}
|
|
root.SetBorderPadding(1, 1, 2, 2).SetBorder(true).SetTitle(
|
|
fmt.Sprintf(" %s • %s ", caser.String(boardType+" issue board"), boardContext),
|
|
)
|
|
|
|
screen, err := tcell.NewScreen()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_ = screen.Init()
|
|
if err := a.SetScreen(screen).SetRoot(root, true).Run(); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
|
|
viewCmd.Flags().
|
|
StringVarP(&opts.assignee, "assignee", "a", "", "Filter board issues by assignee username")
|
|
viewCmd.Flags().
|
|
StringSliceVarP(&opts.labels, "labels", "l", []string{}, "Filter board issues by labels (comma separated)")
|
|
viewCmd.Flags().
|
|
StringVarP(&opts.milestone, "milestone", "m", "", "Filter board issues by milestone")
|
|
return viewCmd
|
|
}
|
|
|
|
func (opts *issueBoardViewOptions) getListProjectIssueOptions() *gitlab.ListProjectIssuesOptions {
|
|
withLabelDetails := true
|
|
reqOpts := &gitlab.ListProjectIssuesOptions{
|
|
WithLabelDetails: &withLabelDetails,
|
|
}
|
|
|
|
if opts.assignee != "" {
|
|
reqOpts.AssigneeUsername = &opts.assignee
|
|
}
|
|
|
|
if len(opts.labels) != 0 {
|
|
labels := gitlab.Labels(opts.labels)
|
|
reqOpts.Labels = &labels
|
|
}
|
|
|
|
if opts.state != "" {
|
|
reqOpts.State = &opts.state
|
|
}
|
|
|
|
if opts.milestone != "" {
|
|
reqOpts.Milestone = &opts.milestone
|
|
}
|
|
return reqOpts
|
|
}
|
|
|
|
func (opts *issueBoardViewOptions) getListGroupIssueOptions() *gitlab.ListGroupIssuesOptions {
|
|
withLabelDetails := true
|
|
reqOpts := &gitlab.ListGroupIssuesOptions{
|
|
WithLabelDetails: &withLabelDetails,
|
|
}
|
|
|
|
if opts.assignee != "" {
|
|
reqOpts.AssigneeUsername = &opts.assignee
|
|
}
|
|
|
|
if len(opts.labels) != 0 {
|
|
labels := gitlab.Labels(opts.labels)
|
|
reqOpts.Labels = &labels
|
|
}
|
|
|
|
if opts.state != "" {
|
|
reqOpts.State = &opts.state
|
|
}
|
|
|
|
if opts.milestone != "" {
|
|
reqOpts.Milestone = &opts.milestone
|
|
}
|
|
return reqOpts
|
|
}
|
|
|
|
func recoverPanic(app *tview.Application) {
|
|
if r := recover(); r != nil {
|
|
app.Stop()
|
|
log.Fatalf("%s\n%s\n", r, string(debug.Stack()))
|
|
}
|
|
}
|
|
|
|
func buildLabelString(labelDetails []*gitlab.LabelDetails) string {
|
|
var labels string
|
|
for _, ld := range labelDetails {
|
|
labels += fmt.Sprintf("[white:%s:-]%s[white:-:-] ", ld.Color, ld.Name)
|
|
}
|
|
if labels != "" {
|
|
labels = strings.TrimSpace(labels) + "\n"
|
|
}
|
|
return labels
|
|
}
|
|
|
|
func selectBoard(menuOptions []string) (string, error) {
|
|
var selectedOption string
|
|
prompt := &survey.Select{
|
|
Message: "Select board:",
|
|
Options: menuOptions,
|
|
}
|
|
err := survey.AskOne(prompt, &selectedOption)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return selectedOption, nil
|
|
}
|
|
|
|
// mapBoardData takes project and group issue board slices and
|
|
// returns menu options for the user selection and a map of the board metadata keyed by the menu options
|
|
func mapBoardData(
|
|
projectIssueBoards []*gitlab.IssueBoard,
|
|
projectGroupIssueBoards []*gitlab.GroupIssueBoard,
|
|
) ([]string, map[string]boardMeta) {
|
|
// find longest board name to base padding on
|
|
maxNameLength := 0
|
|
for _, board := range projectIssueBoards {
|
|
if len(board.Name) > maxNameLength {
|
|
maxNameLength = len(board.Name)
|
|
}
|
|
}
|
|
for _, board := range projectGroupIssueBoards {
|
|
if len(board.Name) > maxNameLength {
|
|
maxNameLength = len(board.Name)
|
|
}
|
|
}
|
|
|
|
minPadding := 3
|
|
menuOptions := []string{}
|
|
boardMetaMap := map[string]boardMeta{}
|
|
|
|
formatMenuOption := func(boardName, parentName string, padding int, isGroupBoard bool) string {
|
|
sb := strings.Builder{}
|
|
sb.WriteString(boardName)
|
|
sb.WriteString(strings.Repeat(" ", padding))
|
|
kind := "PROJECT"
|
|
if isGroupBoard {
|
|
kind = "GROUP"
|
|
}
|
|
sb.WriteString(fmt.Sprintf("(%s: %s)", kind, parentName))
|
|
return sb.String()
|
|
}
|
|
|
|
// build menu entries and map metadata
|
|
for _, board := range projectGroupIssueBoards {
|
|
padding := minPadding
|
|
if maxNameLength-len(board.Name)+3 > minPadding {
|
|
padding = maxNameLength - len(board.Name) + 3
|
|
}
|
|
|
|
option := formatMenuOption(board.Name, board.Group.Name, padding, true)
|
|
menuOptions = append(menuOptions, option)
|
|
boardMetaMap[option] = boardMeta{
|
|
id: board.ID,
|
|
name: board.Name,
|
|
group: board.Group,
|
|
}
|
|
}
|
|
|
|
for _, board := range projectIssueBoards {
|
|
padding := minPadding
|
|
if maxNameLength-len(board.Name)+3 > minPadding {
|
|
padding = maxNameLength - len(board.Name) + 3
|
|
}
|
|
|
|
option := formatMenuOption(board.Name, board.Project.Name, padding, false)
|
|
menuOptions = append(menuOptions, option)
|
|
boardMetaMap[option] = boardMeta{
|
|
id: board.ID,
|
|
name: board.Name,
|
|
project: board.Project,
|
|
}
|
|
}
|
|
return menuOptions, boardMetaMap
|
|
}
|
|
|
|
func getProjectIssueBoards() ([]*gitlab.IssueBoard, error) {
|
|
projectIssueBoards, err := api.ListIssueBoards(
|
|
apiClient,
|
|
repo.FullName(),
|
|
&gitlab.ListIssueBoardsOptions{},
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("retrieving issue board: %w", err)
|
|
}
|
|
return projectIssueBoards, nil
|
|
}
|
|
|
|
func getGroupIssueBoards(
|
|
projectGroups []*gitlab.ProjectGroup,
|
|
gitlabClient *gitlab.Client,
|
|
) ([]*gitlab.GroupIssueBoard, error) {
|
|
projectGroupIssueBoards := []*gitlab.GroupIssueBoard{}
|
|
for _, projectGroup := range projectGroups {
|
|
groupIssueBoards, err := api.ListGroupIssueBoards(
|
|
gitlabClient,
|
|
projectGroup.ID,
|
|
&gitlab.ListGroupIssueBoardsOptions{},
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("retrieving issue board: %w", err)
|
|
}
|
|
projectGroupIssueBoards = append(groupIssueBoards, projectGroupIssueBoards...)
|
|
}
|
|
return projectGroupIssueBoards, nil
|
|
}
|
|
|
|
func getBoardLists(board boardMeta) ([]*gitlab.BoardList, error) {
|
|
boardLists := []*gitlab.BoardList{}
|
|
var err error
|
|
|
|
if board.group != nil {
|
|
boardLists, err = api.GetGroupIssueBoardLists(apiClient, board.group.ID,
|
|
board.id, &gitlab.ListGroupIssueBoardListsOptions{})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if board.group == nil {
|
|
boardLists, err = api.GetIssueBoardLists(apiClient, repo.FullName(),
|
|
board.id, &gitlab.GetIssueBoardListsOptions{})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// add empty 'opened' and 'closed' lists before and after fetched lists
|
|
// these are used later when reading the issues into the table view
|
|
opened := &gitlab.BoardList{
|
|
Label: &gitlab.Label{
|
|
Name: "Open",
|
|
Color: "#fabd2f",
|
|
TextColor: "#000000",
|
|
},
|
|
Position: 0,
|
|
}
|
|
boardLists = append([]*gitlab.BoardList{opened}, boardLists...)
|
|
|
|
closed := &gitlab.BoardList{
|
|
Label: &gitlab.Label{
|
|
Name: "Closed",
|
|
Color: "#8ec07c",
|
|
TextColor: "#000000",
|
|
},
|
|
Position: len(boardLists),
|
|
}
|
|
boardLists = append(boardLists, closed)
|
|
return boardLists, nil
|
|
}
|
|
|
|
func getGroupBoardIssues(groupID int, opts *issueBoardViewOptions) ([]*gitlab.Issue, error) {
|
|
reqOpts := opts.getListGroupIssueOptions()
|
|
issues, err := api.ListGroupIssues(apiClient, groupID, reqOpts)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("retrieving list issues: %w", err)
|
|
}
|
|
return issues, nil
|
|
}
|
|
|
|
func getProjectBoardIssues(opts *issueBoardViewOptions) ([]*gitlab.Issue, error) {
|
|
reqOpts := opts.getListProjectIssueOptions()
|
|
issues, err := api.ListIssues(apiClient, repo.FullName(), reqOpts)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("retrieving list issues: %w", err)
|
|
}
|
|
return issues, nil
|
|
}
|
|
|
|
// filterIssues scans through the issues passed to it, filtering for the ones that belong in targetList
|
|
// This function returns a string representation of the issues for targetList which will be displayed in the table view
|
|
func filterIssues(
|
|
boardLists []*gitlab.BoardList,
|
|
issues []*gitlab.Issue,
|
|
targetList *gitlab.BoardList,
|
|
opts *issueBoardViewOptions,
|
|
) string {
|
|
var boardIssues string
|
|
next:
|
|
for _, issue := range issues {
|
|
switch opts.state {
|
|
// skip all issues that are not in the "closed" state for the "closed" list
|
|
case closed:
|
|
if issue.State != closed {
|
|
continue next
|
|
}
|
|
// skip issues labeled for other board lists when populating the "open" list
|
|
case opened:
|
|
for _, boardList := range boardLists {
|
|
for _, issueLabel := range issue.Labels {
|
|
if issueLabel == boardList.Label.Name {
|
|
continue next
|
|
}
|
|
}
|
|
}
|
|
// filter labeled issues into board lists with corresponding labels
|
|
default:
|
|
var hasListLabel bool
|
|
for _, issueLabel := range issue.Labels {
|
|
if issueLabel == targetList.Label.Name {
|
|
hasListLabel = true
|
|
break
|
|
}
|
|
}
|
|
if !hasListLabel || issue.State == closed {
|
|
continue next
|
|
}
|
|
}
|
|
|
|
var assignee, labelString string
|
|
if len(issue.Labels) > 0 {
|
|
labelString = buildLabelString(issue.LabelDetails)
|
|
}
|
|
if issue.Assignee != nil {
|
|
assignee = issue.Assignee.Username
|
|
}
|
|
|
|
boardIssues += fmt.Sprintf("[white::b]%s\n%s[green:-:-]#%d[darkgray] - %s\n\n",
|
|
issue.Title, labelString, issue.IID, assignee)
|
|
}
|
|
return boardIssues
|
|
}
|