Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions cmd/kosli/dismissProductUpdates.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,18 @@ func (o *dismissProductUpdatesOptions) run(ids []string) error {
}

ids = uniqueNonEmpty(ids)
if err := markProductUpdatesSeen(ids); err != nil {
return err
}
if !global.DryRun {
logger.Info("%d product update(s) dismissed", len(ids))
}
return nil
}

// markProductUpdatesSeen sends ids in requests of at most
// maxProductUpdateIDsPerRequest.
func markProductUpdatesSeen(ids []string) error {
reqURL, err := url.JoinPath(global.Host, "api/v2/product-updates", global.Org, "seen")
if err != nil {
return err
Expand All @@ -125,10 +136,6 @@ func (o *dismissProductUpdatesOptions) run(ids []string) error {
return err
}
}

if !global.DryRun {
logger.Info("%d product update(s) dismissed", len(ids))
}
return nil
}

Expand Down
174 changes: 157 additions & 17 deletions cmd/kosli/listProductUpdates.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"bytes"
"encoding/json"
"fmt"
"io"
Expand All @@ -16,29 +17,51 @@ const listProductUpdatesShortDesc = `List Kosli product updates.`

const listProductUpdatesLongDesc = listProductUpdatesShortDesc + `
Product updates are the new features and improvements published in Kosli's changelog.
Unread updates are listed first, then the newest. An update about a feature that is not
enabled for the org is left out. The first time you list them, in the CLI or in the Kosli app,
every update is marked as seen, so none is shown as unread; only updates published after that
start as unread. Dismiss those with ^kosli dismiss product-updates^.
The update's summary is included in the JSON output.
Only unread updates are listed; use ^--all^ to list the ones you have seen too, unread first,
then the newest. An update about a feature that is not enabled for the org is left out.
The first 10 are listed, as in the Kosli app; use ^--limit^ to list more, or ^--limit 0^ for
no limit. ^--all^ and ^--limit^ apply to the JSON output as well, which also holds each
update's summary. Its ^unread_count^ is always the total number of unread updates, however
many are listed.

An update is unread when you have not seen it and it was published in the last 12 months.
A user who signed up less than 7 days before first listing product updates, in the CLI or in
the Kosli app, starts with every update seen. Listing leaves updates unread: use ^--dismiss^
to mark the unread updates listed as seen, or ^kosli dismiss product-updates^ later.

Product updates are tracked per user, so the API token must belong to a member of the org.`

const listProductUpdatesExample = `
# list product updates:
# list unread product updates:
kosli list product-updates \
--api-token yourAPIToken \
--org yourOrgName

# list product updates in JSON:
# list unread product updates and mark them as seen:
kosli list product-updates \
--dismiss \
--api-token yourAPIToken \
--org yourOrgName

# list every product update, read ones included:
kosli list product-updates \
--all \
--limit 0 \
--api-token yourAPIToken \
--org yourOrgName

# list unread product updates in JSON:
kosli list product-updates \
--output json \
--api-token yourAPIToken \
--org yourOrgName
`

type listProductUpdatesOptions struct {
output string
output string
limit int
all bool
dismiss bool
}

type listProductUpdatesResponse struct {
Expand Down Expand Up @@ -69,18 +92,28 @@ func newListProductUpdatesCmd(out io.Writer) *cobra.Command {
if err != nil {
return ErrorBeforePrintingUsage(cmd, err.Error())
}
return nil
return o.validate(cmd)
},
RunE: func(cmd *cobra.Command, args []string) error {
return o.run(out)
},
}

cmd.Flags().StringVarP(&o.output, "output", "o", "table", outputFlag)
cmd.Flags().IntVar(&o.limit, "limit", 10, productUpdatesLimitFlag)
Comment thread
mbevc1 marked this conversation as resolved.
Resolved
cmd.Flags().BoolVar(&o.all, "all", false, productUpdatesAllFlag)
cmd.Flags().BoolVar(&o.dismiss, "dismiss", false, productUpdatesDismissFlag)

return cmd
}

func (o *listProductUpdatesOptions) validate(cmd *cobra.Command) error {
if o.limit < 0 {
return ErrorBeforePrintingUsage(cmd, "--limit must be 0 or more")
}
return nil
}

func (o *listProductUpdatesOptions) run(out io.Writer) error {
reqURL, err := url.JoinPath(global.Host, "api/v2/product-updates", global.Org)
if err != nil {
Expand All @@ -96,21 +129,115 @@ func (o *listProductUpdatesOptions) run(out io.Writer) error {
return err
}

return output.FormattedPrint(response.Body, o.output, out, 0,
selected, err := selectProductUpdates(response.Body, o.all, o.limit)
if err != nil {
return err
}

err = output.FormattedPrint(selected.body, o.output, out, 0,
map[string]output.FormatOutputFunc{
"table": printProductUpdatesListAsTable,
"json": output.PrintJson,
"table": func(raw string, out io.Writer, page int) error {
return printProductUpdatesListAsTable(raw, out, selected.matching, o.all)
},
"json": output.PrintJson,
})
if err != nil || !o.dismiss || len(selected.unreadIDs) == 0 {
return err
}

if err := markProductUpdatesSeen(selected.unreadIDs); err != nil {
return err
}
// A count after the JSON would make the output unparseable.
if !global.DryRun && o.output != "json" {
logger.Info("%d product update(s) dismissed", len(selected.unreadIDs))
}
return nil
}

func printProductUpdatesListAsTable(raw string, out io.Writer, page int) error {
type selectedProductUpdates struct {
// body is the list response holding only the updates selected.
body string
// matching counts the updates that qualified before the limit cut them.
matching int
// unreadIDs are the ids of the unread updates in body.
unreadIDs []string
}

// selectProductUpdates keeps the unread updates of a list response, or all of
// them with includeRead, and of those the first limit, or all when limit is 0.
func selectProductUpdates(raw string, includeRead bool, limit int) (*selectedProductUpdates, error) {
// Only product_updates is decoded, so the rest of the server's answer
// reaches the JSON output as it was sent.
response := map[string]json.RawMessage{}
if err := json.Unmarshal([]byte(raw), &response); err != nil {
return nil, err
}
updates := []json.RawMessage{}
if listed, ok := response["product_updates"]; ok {
if err := json.Unmarshal(listed, &updates); err != nil {
return nil, err
}
}

selected := &selectedProductUpdates{unreadIDs: []string{}}
kept := []json.RawMessage{}
for _, rawUpdate := range updates {
update := productUpdate{}
if err := json.Unmarshal(rawUpdate, &update); err != nil {
return nil, err
}
if !includeRead && !update.Unread {
continue
}
selected.matching++
if limit != 0 && len(kept) == limit {
continue
}
kept = append(kept, rawUpdate)
if update.Unread {
selected.unreadIDs = append(selected.unreadIDs, update.ID)
}
}

keptJSON, err := marshalKeepingHTMLCharacters(kept)
if err != nil {
return nil, err
}
response["product_updates"] = keptJSON
body, err := marshalKeepingHTMLCharacters(response)
if err != nil {
return nil, err
}
selected.body = string(body)
return selected, nil
}

// marshalKeepingHTMLCharacters encodes v as json.Marshal does, but leaves the
// &, < and > of a title or summary as they are instead of as \u escapes.
func marshalKeepingHTMLCharacters(v any) ([]byte, error) {
encoded := &bytes.Buffer{}
encoder := json.NewEncoder(encoded)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(v); err != nil {
return nil, err
}
return bytes.TrimSpace(encoded.Bytes()), nil
}

func printProductUpdatesListAsTable(raw string, out io.Writer, matching int, includeRead bool) error {
response := &listProductUpdatesResponse{}
if err := json.Unmarshal([]byte(raw), response); err != nil {
return err
}

if len(response.ProductUpdates) == 0 {
logger.Info("No product updates were found.")
shown := len(response.ProductUpdates)
if shown == 0 {
if includeRead {
logger.Info("No product updates were found.")
} else {
logger.Info("No unread product updates. Use --all to list the ones you have seen.")
}
return nil
}

Expand All @@ -125,10 +252,23 @@ func printProductUpdatesListAsTable(raw string, out io.Writer, page int) error {
update.ID, update.Title, publishedAt, update.Unread, update.URL))
}

rows = append(rows, fmt.Sprintf("\n%d unread of %d product updates",
response.UnreadCount, len(response.ProductUpdates)))
rows = append(rows, "\n"+productUpdatesFooter(shown, matching, response.UnreadCount, includeRead))

tabFormattedPrint(out, header, rows)

return nil
}

func productUpdatesFooter(shown, matching, unreadCount int, includeRead bool) string {
const more = " Use --limit to list more."
switch {
case includeRead && shown < matching:
return fmt.Sprintf("Showing %d of %d product updates, %d unread in total.", shown, matching, unreadCount) + more
case includeRead:
return fmt.Sprintf("%d unread of %d product updates", unreadCount, matching)
case shown < matching:
return fmt.Sprintf("Showing %d of %d unread product updates.", shown, matching) + more
default:
return fmt.Sprintf("%d unread product update(s)", matching)
}
}
Loading
Loading