From 3cce29287fe11997500105645abb564b7c56f27a Mon Sep 17 00:00:00 2001 From: Igor Sirotin Date: Tue, 9 Jul 2024 23:42:28 +0100 Subject: [PATCH] feat: issue author assignee (#70) * feat: issue author and assignee * chore: ui cleanup * fix(demo): prevent stopping non-created players * test: parse url * test: TestSplitLabelsToLines * test: github issue fetch --- cmd/2sp/demo/demo.go | 33 ++- internal/view/DESIGN.md | 17 +- internal/view/components/issueview/fetch.go | 137 ++++++++++ .../view/components/issueview/fetch_test.go | 213 ++++++++++++++++ .../view/components/issueview/issue_info.go | 59 +++++ .../view/components/issueview/issue_view.go | 233 ++++++++---------- .../components/issueview/issue_view_test.go | 73 ++++++ .../components/issueview/issues_service.go | 13 + .../wakustatusview/wakuStatusView.go | 3 +- internal/view/render.go | 11 +- pkg/game/game_handle.go | 6 +- 11 files changed, 646 insertions(+), 152 deletions(-) create mode 100644 internal/view/components/issueview/fetch.go create mode 100644 internal/view/components/issueview/fetch_test.go create mode 100644 internal/view/components/issueview/issue_info.go create mode 100644 internal/view/components/issueview/issue_view_test.go create mode 100644 internal/view/components/issueview/issues_service.go diff --git a/cmd/2sp/demo/demo.go b/cmd/2sp/demo/demo.go index bb46068..d4c6169 100644 --- a/cmd/2sp/demo/demo.go +++ b/cmd/2sp/demo/demo.go @@ -63,8 +63,8 @@ func (d *Demo) Stop() { func (d *Demo) initializePlayers() error { names := []string{"Alice", "Bob", "Charlie"} - d.players = make([]*game.Game, len(names)) - d.playerSubs = make([]game.StateSubscription, len(names)) + d.players = make([]*game.Game, 0, len(names)) + d.playerSubs = make([]game.StateSubscription, 0, len(names)) wg := sync.WaitGroup{} errChan := make(chan error, len(names)) @@ -78,8 +78,8 @@ func (d *Demo) initializePlayers() error { errChan <- errors.Wrap(err, "failed to create player") return } - d.players[i] = player - d.playerSubs[i] = player.SubscribeToStateChanges() + d.players = append(d.players, player) + d.playerSubs = append(d.playerSubs, player.SubscribeToStateChanges()) }(i, name) } @@ -99,14 +99,19 @@ func (d *Demo) Routine() { defer d.Stop() d.logger.Info("started") - time.Sleep(2 * time.Second) // Wait for the program to start + + err := d.waitForGameInitialized() + if err != nil { + d.logger.Error("game wasn't initialized", zap.Error(err)) + return + } // Create new room d.sendShortcut(commands.DefaultKeyMap.NewRoom) d.logger.Info("room created") // Add players - err := d.initializePlayers() + err = d.initializePlayers() if err != nil { d.logger.Error("failed to initialize players", zap.Error(err)) return @@ -338,3 +343,19 @@ func (d *Demo) waitForIssueDealt(sub game.StateSubscription, issueID protocol.Is return state.ActiveIssue == issueID }) } + +func (d *Demo) waitForGameInitialized() error { + timeout := time.After(10 * time.Second) + for { + select { + case <-d.ctx.Done(): + return errors.New("context done") + case <-timeout: + return errors.New("timeout waiting for game to be initialized") + case <-time.After(100 * time.Millisecond): + if d.dealer.Initialized() { + return nil + } + } + } +} diff --git a/internal/view/DESIGN.md b/internal/view/DESIGN.md index 2f9ced4..08df4b2 100644 --- a/internal/view/DESIGN.md +++ b/internal/view/DESIGN.md @@ -1,20 +1,19 @@ -package view - # Full example ## Room view ```shell - ● Waku: 8 peer(s) - Room: W1Rka5Dz8GVGzgy1iq6gS5puS1x3JroeW4ZDTfBZkCfm (dealer) + ● Waku: 8 peer(s) | ● Room: W1Rka5Dz8GVGzgy1iq6gS5puS1x3JroeW4ZDTfBZkCfm (dealer) + + https://github.com/golang/go/issues/19412 + proposal: spec: add sum types / discriminated unions #19412 + + Author: @Alice [LanguageChange] [v2] [Proposal] + Assignee: @Bob [NeedsInvestigation] - Issue: https://github.com/golang/go/issues/19412 - proposal: spec: add sum types / discriminated unions - [LanguageChange] [v2] [Proposal] [NeedsInvestigation] - ╭───────┬───────────┬─────────┬───────┬──────╮ - │ Alice │ Bob (You) │ Charlie │ David │ Erin │ Recommended: 8 + │ Alice │ Bob (You) │ Charlie │ David │ Erin │ Recommended: 8 ├───────┼───────────┼─────────┼───────┼──────┤ Acceptable: ✓ │ 8 │ 8 │ 5 │ 5 │ 8 │ > Not bad. ╰───────┴───────────┴─────────┴───────┴──────╯ diff --git a/internal/view/components/issueview/fetch.go b/internal/view/components/issueview/fetch.go new file mode 100644 index 0000000..933788a --- /dev/null +++ b/internal/view/components/issueview/fetch.go @@ -0,0 +1,137 @@ +package issueview + +import ( + "context" + "net/url" + "strconv" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" + "github.com/pkg/errors" + + "github.com/six78/2-story-points-cli/internal/config" + "github.com/six78/2-story-points-cli/pkg/protocol" +) + +var ( + errOnlyGithubIssuesUnfurled = errors.New("only github issues can be unfurled") + errInvalidGithubIssueLink = errors.New("invalid github issue link") + errInvalidGithubIssueNumber = errors.New("invalid github issue number") + errGithubIssueFetchFailed = errors.New("failed to fetch github issue") +) + +type githubIssueRequest struct { + owner string + repo string + number int +} + +func parseUrl(input string) (*githubIssueRequest, error) { + u, err := url.Parse(input) + if err != nil { + return nil, nil + } + if u.Host != "github.com" { + return nil, errOnlyGithubIssuesUnfurled + } + path := strings.Split(u.Path, "/") + if len(path) != 5 { + return nil, errInvalidGithubIssueLink + } + + issueNumber, err := strconv.Atoi(path[4]) + if err != nil { + return nil, errInvalidGithubIssueNumber + } + + return &githubIssueRequest{ + owner: path[1], + repo: path[2], + number: issueNumber, + }, nil +} + +func fetchIssue(client GithubIssueService, input *protocol.Issue) tea.Cmd { + return func() tea.Msg { + if input == nil { + return nil + } + request, err := parseUrl(input.TitleOrURL) + if err != nil { + return issueFetchedMessage{ + url: input.TitleOrURL, + info: &issueInfo{ + err: err, + }, + } + } + if request == nil { + return nil + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) + defer cancel() + + issue, _, err := client.Get(ctx, request.owner, request.repo, request.number) + if err != nil { + return issueFetchedMessage{ + url: input.TitleOrURL, + info: &issueInfo{ + err: errGithubIssueFetchFailed, + }, + } + } + + labels := make([]labelInfo, len(issue.Labels)) + for i, label := range issue.Labels { + labels[i].name = label.Name + labels[i].style = labelStyle(label.Color) + } + + msg := issueFetchedMessage{ + url: input.TitleOrURL, + info: &issueInfo{ + err: nil, + number: issue.Number, + title: issue.Title, + labels: labels, + }, + } + + if issue.User != nil { + msg.info.author = issue.User.Login + } + + if issue.Assignee != nil { + msg.info.assignee = issue.Assignee.Login + } + + return msg + } +} + +func labelStyle(input *string) lipgloss.Style { + if input == nil { + return lipgloss.NewStyle().Foreground(config.ForegroundShadeColor) + } + + color := lipgloss.Color("#" + *input) + dark := colorIsDark(color) + + if lipgloss.DefaultRenderer().HasDarkBackground() == dark { + return lipgloss.NewStyle().Background(color) + } + + return lipgloss.NewStyle().Foreground(color) +} + +func colorIsDark(color lipgloss.Color) bool { + renderer := lipgloss.DefaultRenderer() + c := renderer.ColorProfile().Color(string(color)) + rgb := termenv.ConvertToRGB(c) + //_, _, lightness := rgb.Hsl() + perceivedLightness := 0.2126*rgb.R + 0.7152*rgb.G + 0.0722*rgb.B + return perceivedLightness < 0.453 +} diff --git a/internal/view/components/issueview/fetch_test.go b/internal/view/components/issueview/fetch_test.go new file mode 100644 index 0000000..e2b3d40 --- /dev/null +++ b/internal/view/components/issueview/fetch_test.go @@ -0,0 +1,213 @@ +package issueview + +import ( + "fmt" + "reflect" + "testing" + + "github.com/brianvoe/gofakeit/v6" + "github.com/google/go-github/v61/github" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + mock "github.com/six78/2-story-points-cli/internal/view/components/issueview/mock" + "github.com/six78/2-story-points-cli/pkg/protocol" +) + +func fakeIssueURL() (*githubIssueRequest, string) { + request := &githubIssueRequest{ + owner: gofakeit.LetterN(5), + repo: gofakeit.LetterN(5), + number: gofakeit.Number(1, 1000), + } + url := fmt.Sprintf("https://github.com/%s/%s/issues/%d", + request.owner, + request.repo, + request.number) + return request, url +} + +func TestParseUrl(t *testing.T) { + expectedRequest, validURL := fakeIssueURL() + + testCases := []struct { + name string + url string + expectedResult *githubIssueRequest + expectedError error + }{ + { + name: "Not a URL", + url: "://not-a-url", + expectedResult: nil, + expectedError: nil, // We want not-urls to be silently ignored + }, + { + name: "Not a GitHub URL", + url: "https://example.com", + expectedResult: nil, + expectedError: errOnlyGithubIssuesUnfurled, + }, + { + name: "Invalid GitHub issue link", + url: "https://github.com/url/path/should/have/exactly/five/parts", + expectedResult: nil, + expectedError: errInvalidGithubIssueLink, + }, + { + name: "Invalid GitHub issue number", + url: "https://github.com/owner/repo/issues/issue-number-is-not-a-number", + expectedResult: nil, + expectedError: errInvalidGithubIssueNumber, + }, + { + name: "Valid GitHub issue URL", + url: validURL, + expectedResult: expectedRequest, + expectedError: nil, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + request, err := parseUrl(tc.url) + require.ErrorIs(t, err, tc.expectedError) + require.True(t, reflect.DeepEqual(tc.expectedResult, request)) + }) + } +} + +// TestParseError +// TestFetchError + +func TestFetchIssue(t *testing.T) { + ctrl := gomock.NewController(t) + client, issue, ghIssue := setupClientWithGeneratedIssue(ctrl) + + cmd := fetchIssue(client, issue) + require.NotNil(t, cmd) + + msg := cmd() + require.NotNil(t, msg) + + issueMessage := msg.(issueFetchedMessage) + require.NoError(t, issueMessage.info.err) + require.Equal(t, *ghIssue.Number, *issueMessage.info.number) + require.Equal(t, *ghIssue.Title, *issueMessage.info.title) + require.Equal(t, *ghIssue.User.Login, *issueMessage.info.author) + require.Equal(t, *ghIssue.Assignee.Login, *issueMessage.info.assignee) + require.Len(t, issueMessage.info.labels, len(ghIssue.Labels)) + for i, label := range ghIssue.Labels { + require.Equal(t, *label.Name, *issueMessage.info.labels[i].name) + } +} + +func TestParseURLError(t *testing.T) { + issue := &protocol.Issue{ + ID: protocol.IssueID(gofakeit.UUID()), + TitleOrURL: "https://example.com", + } + + cmd := fetchIssue(nil, issue) + require.NotNil(t, cmd) + + msg := cmd() + require.NotNil(t, msg) + + issueMessage := msg.(issueFetchedMessage) + require.Equal(t, issueMessage.url, issue.TitleOrURL) + require.ErrorIs(t, issueMessage.info.err, errOnlyGithubIssuesUnfurled) +} + +func TestNotURL(t *testing.T) { + issue := &protocol.Issue{ + ID: protocol.IssueID(gofakeit.UUID()), + TitleOrURL: "://not-a-url", + } + + cmd := fetchIssue(nil, issue) + require.NotNil(t, cmd) + + msg := cmd() + require.Nil(t, msg) +} + +func TestFetchError(t *testing.T) { + issue, _, urlInfo := generateIssue() + + ctrl := gomock.NewController(t) + client := mock.NewMockGithubIssueService(ctrl) + + client.EXPECT(). + Get(gomock.Any(), urlInfo.owner, urlInfo.repo, urlInfo.number). + Return(nil, nil, fmt.Errorf("error")). + Times(1) + + cmd := fetchIssue(client, issue) + require.NotNil(t, cmd) + + msg := cmd() + require.NotNil(t, msg) + + issueMessage := msg.(issueFetchedMessage) + require.Equal(t, issueMessage.url, issue.TitleOrURL) + require.ErrorIs(t, issueMessage.info.err, errGithubIssueFetchFailed) +} + +func TestFetchNil(t *testing.T) { + cmd := fetchIssue(nil, nil) + require.NotNil(t, cmd) + + msg := cmd() + require.Nil(t, msg) +} + +func generateIssue() (*protocol.Issue, *github.Issue, *githubIssueRequest) { + urlInfo, url := fakeIssueURL() + + issue := &protocol.Issue{ + ID: protocol.IssueID(gofakeit.UUID()), + TitleOrURL: url, + } + + // Generate issue + title := gofakeit.LetterN(10) + author := gofakeit.LetterN(5) + assignee := gofakeit.LetterN(5) + ghIssue := &github.Issue{ + Number: &urlInfo.number, + Title: &title, + User: &github.User{ + Login: &author, + }, + Assignee: &github.User{ + Login: &assignee, + }, + } + for i := 0; i < gofakeit.Number(1, 3); i++ { + labelLength := uint(gofakeit.Number(2, 20)) + name := gofakeit.LetterN(labelLength) + label := &github.Label{ + Name: &name, + } + if i == 0 { + color := gofakeit.HexColor() + label.Color = &color + } + ghIssue.Labels = append(ghIssue.Labels, label) + } + + return issue, ghIssue, urlInfo +} + +func setupClientWithGeneratedIssue(ctrl *gomock.Controller) (GithubIssueService, *protocol.Issue, *github.Issue) { + issue, ghIssue, urlInfo := generateIssue() + + client := mock.NewMockGithubIssueService(ctrl) + client.EXPECT(). + Get(gomock.Any(), urlInfo.owner, urlInfo.repo, urlInfo.number). + Return(ghIssue, nil, nil). + Times(1) + + return client, issue, ghIssue +} diff --git a/internal/view/components/issueview/issue_info.go b/internal/view/components/issueview/issue_info.go new file mode 100644 index 0000000..c23e948 --- /dev/null +++ b/internal/view/components/issueview/issue_info.go @@ -0,0 +1,59 @@ +package issueview + +import ( + "fmt" + + "github.com/charmbracelet/lipgloss" +) + +const ( + loginPrefix = "@" +) + +type issueInfo struct { + err error + number *int + title *string + labels []labelInfo + author *string + assignee *string +} + +type labelInfo struct { + name *string + style lipgloss.Style +} + +func renderNumber(info *issueInfo) string { + if info == nil { + return "" + } + if info.number == nil { + return "" + } + return fmt.Sprintf("#%d", *info.number) +} + +func authorString(info *issueInfo) string { + if info == nil { + return "" + } + + if info.author == nil { + return "" + } + + return loginPrefix + *info.author +} + +func assigneeString(info *issueInfo) string { + if info == nil { + return "" + } + + if info.assignee == nil { + return "" + } + + return loginPrefix + *info.assignee +} diff --git a/internal/view/components/issueview/issue_view.go b/internal/view/components/issueview/issue_view.go index 534eaef..91626c0 100644 --- a/internal/view/components/issueview/issue_view.go +++ b/internal/view/components/issueview/issue_view.go @@ -1,28 +1,30 @@ package issueview import ( - "context" "fmt" - "net/url" - "strconv" "strings" - "time" "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/google/go-github/v61/github" - "github.com/muesli/termenv" - "github.com/pkg/errors" + "go.uber.org/zap" + "github.com/six78/2-story-points-cli/internal/config" "github.com/six78/2-story-points-cli/internal/view/messages" "github.com/six78/2-story-points-cli/pkg/protocol" - "go.uber.org/zap" ) var ( - errorStyle = lipgloss.NewStyle().Foreground(config.ForegroundShadeColor) - //defaultStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#555555")) + headerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")) + primaryStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#F0F0F0")) + secondaryStyle = lipgloss.NewStyle().Foreground(config.ForegroundShadeColor) + hyperlinkStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#648EF8")).Underline(true) + errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF1744")) +) + +const ( + viewHeight = 5 ) type issueFetchedMessage struct { @@ -38,17 +40,6 @@ type Model struct { spinner spinner.Model } -type issueInfo struct { - err error - title *string - labels []labelInfo -} - -type labelInfo struct { - name *string - style lipgloss.Style -} - func New() Model { s := spinner.New() s.Spinner = spinner.MiniDot @@ -87,14 +78,12 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { if ok { break } - cmd = fetchIssue(m.client, m.issue) + cmd = fetchIssue(m.client.Issues, m.issue) cmds = append(cmds, cmd) m.issues[m.issue.TitleOrURL] = nil case issueFetchedMessage: - config.Logger.Debug("<<< issue fetched", - zap.Any("msg", msg), - ) + config.Logger.Debug("issue fetched", zap.Any("msg", msg)) m.issues[msg.url] = msg.info } @@ -105,44 +94,71 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { } func (m Model) View() string { - rightColumn := lipgloss.JoinVertical(lipgloss.Top, - m.renderRow1(), - m.renderInfo()) - return lipgloss.JoinHorizontal(lipgloss.Left, - "Issue: \n\n", // Fill at least 3 lines - rightColumn) + if m.issue == nil { + return lipgloss.JoinVertical(lipgloss.Center, + " ", + secondaryStyle.Render("No active issue"), + strings.Repeat("\n", viewHeight-3), + ) + } + + info := m.issueInfo() + labelsFirstLine, labelsSecondLine := renderLabelLines(info) + + block := lipgloss.JoinHorizontal(lipgloss.Top, + lipgloss.JoinVertical(lipgloss.Left, + headerStyle.Render("Author:"), + headerStyle.Render("Assignee:"), + ), + " ", + lipgloss.JoinVertical(lipgloss.Left, + primaryStyle.Render(fmt.Sprintf("%-20s", authorString(info))), + primaryStyle.Render(fmt.Sprintf("%-20s", assigneeString(info))), + ), + lipgloss.JoinVertical(lipgloss.Top, + labelsFirstLine, + labelsSecondLine, + ), + ) + + return lipgloss.JoinVertical(lipgloss.Left, + m.renderHeader(), + m.renderTitle(info), + "", + block, + ) } -func (m *Model) renderRow1() string { +func (m *Model) renderHeader() string { if m.issue != nil { - return m.issue.TitleOrURL + return hyperlinkStyle.Render(m.issue.TitleOrURL) } return "-" } -func (m *Model) renderInfo() string { +func (m *Model) renderTitle(info *issueInfo) string { if m.issue == nil { return "" } - info, ok := m.issues[m.issue.TitleOrURL] - if !ok { - return "" - } - if info == nil { - return errorStyle.Render(m.spinner.View() + " fetching title") + return secondaryStyle.Render(m.spinner.View() + " fetching title") } if info.err != nil { return errorStyle.Render(fmt.Sprintf("[%s]", info.err.Error())) } - row1 := "" if info.title == nil { - row1 = errorStyle.Render("[empty issue title]") - } else { - row1 = *info.title + return secondaryStyle.Render("[empty issue title]") + } + + return primaryStyle.Render(*info.title) + " " + secondaryStyle.Render(renderNumber(info)) +} + +func renderLabels(info *issueInfo) []string { + if info == nil { + return []string{} } var labels []string @@ -153,109 +169,64 @@ func (m *Model) renderInfo() string { labelName := fmt.Sprintf("[%s]", *l.name) labels = append(labels, l.style.Render(labelName)) } - row2 := strings.Join(labels, " ") - return lipgloss.JoinVertical(lipgloss.Top, row1, row2) + return labels } -type githubIssueRequest struct { - owner string - repo string - number int -} - -func parseUrl(input string) (*githubIssueRequest, error) { - u, err := url.Parse(input) - if err != nil { - return nil, nil - } - if u.Host != "github.com" { - return nil, errors.New("only github links are unfurled") - } - path := strings.Split(u.Path, "/") - if len(path) != 5 { - return nil, errors.New("invalid github issue link") - } - - issueNumber, err := strconv.Atoi(path[4]) - if err != nil { - return nil, errors.New("invalid github issue number") +func splitLabelsToLines(labels []labelInfo) int { + // Calculate full length, ignore space between labels + fullLength := 0 + for _, l := range labels { + if l.name == nil { + continue + } + fullLength += len(*l.name) } - return &githubIssueRequest{ - owner: path[1], - repo: path[2], - number: issueNumber, - }, nil -} - -func fetchIssue(client *github.Client, input *protocol.Issue) tea.Cmd { - return func() tea.Msg { - if input == nil { - return nil - } - request, err := parseUrl(input.TitleOrURL) - if err != nil { - return issueFetchedMessage{ - url: input.TitleOrURL, - info: &issueInfo{ - err: err, - }, - } - } - if request == nil { - return nil + // Find the index where the first line should end + firstLineLength := 0 + for i, label := range labels { + if label.name == nil { + continue } - ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) - defer cancel() - - issue, _, err := client.Issues.Get(ctx, request.owner, request.repo, request.number) - if err != nil { - return issueFetchedMessage{ - url: input.TitleOrURL, - info: &issueInfo{ - err: errors.New("failed to fetch github issue"), - }, + if firstLineLength+len(*label.name) > fullLength/2 { + // Ensure at least one item remains on the first line + if i == 0 { + return 1 } + return i } + firstLineLength += len(*label.name) + } - labels := make([]labelInfo, len(issue.Labels)) - for i, label := range issue.Labels { - labels[i].name = label.Name - labels[i].style = labelStyle(label.Color) - } + return len(labels) +} - return issueFetchedMessage{ - url: input.TitleOrURL, - info: &issueInfo{ - err: nil, - title: issue.Title, - labels: labels, - }, - } - } +func joinLabels(labels []string) string { + return strings.Join(labels, " ") } -func labelStyle(input *string) lipgloss.Style { - if input == nil { - return lipgloss.NewStyle().Foreground(config.ForegroundShadeColor) +func renderLabelLines(info *issueInfo) (string, string) { + if info == nil { + return "", "" } + if len(info.labels) == 0 { + return "", "" + } + labels := renderLabels(info) + splitIndex := splitLabelsToLines(info.labels) + return joinLabels(labels[:splitIndex]), joinLabels(labels[splitIndex:]) +} - color := lipgloss.Color("#" + *input) - dark := colorIsDark(color) - - if lipgloss.DefaultRenderer().HasDarkBackground() == dark { - return lipgloss.NewStyle().Background(color) +func (m *Model) issueInfo() *issueInfo { + if m.issue == nil { + return nil } - return lipgloss.NewStyle().Foreground(color) -} + info, ok := m.issues[m.issue.TitleOrURL] + if !ok { + return nil + } -func colorIsDark(color lipgloss.Color) bool { - renderer := lipgloss.DefaultRenderer() - c := renderer.ColorProfile().Color(string(color)) - rgb := termenv.ConvertToRGB(c) - //_, _, lightness := rgb.Hsl() - perceivedLightness := 0.2126*rgb.R + 0.7152*rgb.G + 0.0722*rgb.B - return perceivedLightness < 0.453 + return info } diff --git a/internal/view/components/issueview/issue_view_test.go b/internal/view/components/issueview/issue_view_test.go new file mode 100644 index 0000000..1c82fb3 --- /dev/null +++ b/internal/view/components/issueview/issue_view_test.go @@ -0,0 +1,73 @@ +package issueview + +import ( + "fmt" + "strings" + "testing" + + "github.com/brianvoe/gofakeit/v6" + "github.com/stretchr/testify/require" + + "github.com/six78/2-story-points-cli/internal/testcommon" + "github.com/six78/2-story-points-cli/internal/view/messages" + "github.com/six78/2-story-points-cli/pkg/protocol" +) + +func TestHeight(t *testing.T) { + _ = testcommon.SetupConfigLogger(t) + + model := New() + + var issue protocol.Issue + err := gofakeit.Struct(&issue) + require.NoError(t, err) + + var info issueInfo + err = gofakeit.Struct(&info) + require.NoError(t, err) + + model, _ = model.Update(messages.GameStateMessage{ + State: &protocol.State{ + Issues: protocol.IssuesList{&issue}, + ActiveIssue: issue.ID, + }, + }) + + model, _ = model.Update(issueFetchedMessage{ + url: issue.TitleOrURL, + info: &info, + }) + + view := model.View() + lines := strings.Split(view, "\n") + require.Len(t, lines, viewHeight) +} + +func TestSplitLabelsToLines(t *testing.T) { + testCases := []struct { + labelsSizes []uint + expectedSplit int + }{ + {labelsSizes: []uint{1}, expectedSplit: 1}, + {labelsSizes: []uint{1, 1}, expectedSplit: 1}, + {labelsSizes: []uint{1, 1, 1}, expectedSplit: 1}, + {labelsSizes: []uint{1, 1, 1, 1}, expectedSplit: 2}, + {labelsSizes: []uint{1, 1, 1, 1}, expectedSplit: 2}, + {labelsSizes: []uint{10, 1, 1, 1}, expectedSplit: 1}, // at least one issue at first line + {labelsSizes: []uint{1, 1, 1, 10}, expectedSplit: 3}, + } + + for _, tc := range testCases { + name := fmt.Sprint(tc.labelsSizes) + t.Run(name, func(t *testing.T) { + labels := make([]labelInfo, len(tc.labelsSizes)) + for i := range labels { + label := gofakeit.LetterN(tc.labelsSizes[i]) + labels[i].name = &label + } + + splitIndex := splitLabelsToLines(labels) + require.Equal(t, tc.expectedSplit, splitIndex) + }) + } +} diff --git a/internal/view/components/issueview/issues_service.go b/internal/view/components/issueview/issues_service.go new file mode 100644 index 0000000..f972f5d --- /dev/null +++ b/internal/view/components/issueview/issues_service.go @@ -0,0 +1,13 @@ +package issueview + +import ( + "context" + + "github.com/google/go-github/v61/github" +) + +//go:generate mockgen -source=issues_service.go -destination=mock/issues_service.go + +type GithubIssueService interface { + Get(ctx context.Context, owner string, repo string, number int) (*github.Issue, *github.Response, error) +} diff --git a/internal/view/components/wakustatusview/wakuStatusView.go b/internal/view/components/wakustatusview/wakuStatusView.go index f43155d..57e2a56 100644 --- a/internal/view/components/wakustatusview/wakuStatusView.go +++ b/internal/view/components/wakustatusview/wakuStatusView.go @@ -5,6 +5,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/six78/2-story-points-cli/internal/transport" "github.com/six78/2-story-points-cli/internal/view/messages" ) @@ -45,7 +46,7 @@ func (m Model) View() string { marker = dangerStyle.Render(marker) } - text := fmt.Sprintf(" Waku connection status: %d peer(s)", m.status.PeersCount) + text := fmt.Sprintf(" Waku: %d peer(s)", m.status.PeersCount) return lipgloss.JoinHorizontal(lipgloss.Left, marker, text) } diff --git a/internal/view/render.go b/internal/view/render.go index 11a2c5a..b482021 100644 --- a/internal/view/render.go +++ b/internal/view/render.go @@ -52,8 +52,11 @@ func (m model) renderGame() string { roomViewSeparator = "\n" } return lipgloss.JoinVertical(lipgloss.Top, - m.wakuStatusView.View(), - m.renderRoomID(), + lipgloss.JoinHorizontal(lipgloss.Top, + m.wakuStatusView.View(), + foregroundShadeStyle.Render(" | "), + m.renderRoomID(), + ), roomViewSeparator+m.renderRoomView(), m.renderActionInput(), m.errorView.View()) @@ -61,13 +64,13 @@ func (m model) renderGame() string { func (m model) renderRoomID() string { if m.roomID.Empty() { - return " Join a room or create a new one ..." + return "Join a room or create a new one ..." } var dealerString string if m.game.IsDealer() { dealerString = foregroundShadeStyle.Render(" (dealer)") } - return " Room: " + m.roomID.String() + dealerString + return "Room: " + m.roomID.String() + dealerString } func (m model) renderRoomView() string { diff --git a/pkg/game/game_handle.go b/pkg/game/game_handle.go index 0487a33..1b42852 100644 --- a/pkg/game/game_handle.go +++ b/pkg/game/game_handle.go @@ -3,9 +3,10 @@ package game import ( "encoding/json" - "github.com/six78/2-story-points-cli/pkg/protocol" "go.uber.org/zap" "golang.org/x/exp/slices" + + "github.com/six78/2-story-points-cli/pkg/protocol" ) func (g *Game) handleStateMessage(payload []byte) { @@ -66,6 +67,9 @@ func (g *Game) handlePlayerOnlineMessage(payload []byte) { } func (g *Game) handlePlayerOfflineMessage(payload []byte) { + if g.state == nil { + return + } var message protocol.PlayerOfflineMessage err := json.Unmarshal(payload, &message) if err != nil {