зеркало из https://github.com/mislav/hub.git
612 строки
16 KiB
Go
612 строки
16 KiB
Go
package commands
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/github/hub/git"
|
|
"github.com/github/hub/github"
|
|
"github.com/github/hub/ui"
|
|
"github.com/github/hub/utils"
|
|
)
|
|
|
|
var (
|
|
cmdIssue = &Command{
|
|
Run: listIssues,
|
|
Usage: `
|
|
issue [-a <ASSIGNEE>] [-c <CREATOR>] [-@ <USER>] [-s <STATE>] [-f <FORMAT>] [-M <MILESTONE>] [-l <LABELS>] [-d <DATE>] [-o <SORT_KEY> [-^]] [-L <LIMIT>]
|
|
issue show [-f <FORMAT>] <NUMBER>
|
|
issue create [-oc] [-m <MESSAGE>|-F <FILE>] [--edit] [-a <USERS>] [-M <MILESTONE>] [-l <LABELS>]
|
|
issue labels [--color]
|
|
`,
|
|
Long: `Manage GitHub issues for the current project.
|
|
|
|
## Commands:
|
|
|
|
With no arguments, show a list of open issues.
|
|
|
|
* _show_:
|
|
Show an existing issue specified by <NUMBER>.
|
|
|
|
* _create_:
|
|
Open an issue in the current project.
|
|
|
|
* _labels_:
|
|
List the labels available in this repository.
|
|
|
|
## Options:
|
|
-a, --assignee=<ASSIGNEE>
|
|
Display only issues assigned to <ASSIGNEE>.
|
|
|
|
When opening an issue, this can be a comma-separated list of people to
|
|
assign to the new issue.
|
|
|
|
-c, --creator=<CREATOR>
|
|
Display only issues created by <CREATOR>.
|
|
|
|
-@, --mentioned=<USER>
|
|
Display only issues mentioning <USER>.
|
|
|
|
-s, --state=<STATE>
|
|
Display issues with state <STATE> (default: "open").
|
|
|
|
-f, --format=<FORMAT>
|
|
Pretty print the contents of the issues using format <FORMAT> (default:
|
|
"%sC%>(8)%i%Creset %t% l%n"). See the "PRETTY FORMATS" section of
|
|
git-log(1) for some additional details on how placeholders are used in
|
|
format. The available placeholders for issues are:
|
|
|
|
%I: issue number
|
|
|
|
%i: issue number prefixed with "#"
|
|
|
|
%U: the URL of this issue
|
|
|
|
%S: state (i.e. "open", "closed")
|
|
|
|
%sC: set color to red or green, depending on issue state.
|
|
|
|
%t: title
|
|
|
|
%l: colored labels
|
|
|
|
%L: raw, comma-separated labels
|
|
|
|
%b: body
|
|
|
|
%au: login name of author
|
|
|
|
%as: comma-separated list of assignees
|
|
|
|
%Mn: milestone number
|
|
|
|
%Mt: milestone title
|
|
|
|
%NC: number of comments
|
|
|
|
%Nc: number of comments wrapped in parentheses, or blank string if zero.
|
|
|
|
%cD: created date-only (no time of day)
|
|
|
|
%cr: created date, relative
|
|
|
|
%ct: created date, UNIX timestamp
|
|
|
|
%cI: created date, ISO 8601 format
|
|
|
|
%uD: updated date-only (no time of day)
|
|
|
|
%ur: updated date, relative
|
|
|
|
%ut: updated date, UNIX timestamp
|
|
|
|
%uI: updated date, ISO 8601 format
|
|
|
|
%n: newline
|
|
|
|
%%: a literal %
|
|
|
|
-m, --message=<MESSAGE>
|
|
The text up to the first blank line in <MESSAGE> is treated as the issue
|
|
title, and the rest is used as issue description in Markdown format.
|
|
|
|
If multiple <MESSAGE> options are given, their values are concatenated as
|
|
separate paragraphs.
|
|
|
|
-F, --file=<FILE>
|
|
Read the issue title and description from <FILE>.
|
|
|
|
-e, --edit
|
|
Further edit the contents of <FILE> in a text editor before submitting.
|
|
|
|
-o, --browse
|
|
Open the new issue in a web browser.
|
|
|
|
-c, --copy
|
|
Put the URL of the new issue to clipboard instead of printing it.
|
|
|
|
-M, --milestone=<ID>
|
|
Display only issues for a GitHub milestone with id <ID>.
|
|
|
|
When opening an issue, add this issue to a GitHub milestone with id <ID>.
|
|
|
|
-l, --labels=<LABELS>
|
|
Display only issues with certain labels.
|
|
|
|
When opening an issue, add a comma-separated list of labels to this issue.
|
|
|
|
-d, --since=<DATE>
|
|
Display only issues updated on or after <DATE> in ISO 8601 format.
|
|
|
|
-o, --sort=<SORT_KEY>
|
|
Sort displayed issues by "created" (default), "updated" or "comments".
|
|
|
|
-^ --sort-ascending
|
|
Sort by ascending dates instead of descending.
|
|
|
|
-L, --limit=<LIMIT>
|
|
Display only the first <LIMIT> issues.
|
|
|
|
--include-pulls
|
|
Include pull requests as well as issues.
|
|
|
|
--color
|
|
Enable colored output for labels list.
|
|
`,
|
|
}
|
|
|
|
cmdCreateIssue = &Command{
|
|
Key: "create",
|
|
Run: createIssue,
|
|
Usage: "issue create [-o] [-m <MESSAGE>|-F <FILE>] [-a <USERS>] [-M <MILESTONE>] [-l <LABELS>]",
|
|
Long: "Open an issue in the current project.",
|
|
}
|
|
|
|
cmdShowIssue = &Command{
|
|
Key: "show",
|
|
Run: showIssue,
|
|
Usage: "issue show <NUMBER>",
|
|
Long: "Show an issue in the current project.",
|
|
}
|
|
|
|
cmdLabel = &Command{
|
|
Key: "labels",
|
|
Run: listLabels,
|
|
Usage: "issue labels [--color]",
|
|
Long: "List the labels available in this repository.",
|
|
}
|
|
|
|
flagIssueAssignee,
|
|
flagIssueState,
|
|
flagIssueFormat,
|
|
flagShowIssueFormat,
|
|
flagIssueMilestoneFilter,
|
|
flagIssueCreator,
|
|
flagIssueMentioned,
|
|
flagIssueLabelsFilter,
|
|
flagIssueSince,
|
|
flagIssueSort,
|
|
flagIssueFile string
|
|
|
|
flagIssueMessage messageBlocks
|
|
|
|
flagIssueEdit,
|
|
flagIssueCopy,
|
|
flagIssueBrowse,
|
|
flagIssueSortAscending bool
|
|
flagIssueIncludePulls bool
|
|
|
|
flagIssueMilestone uint64
|
|
|
|
flagIssueAssignees,
|
|
flagIssueLabels listFlag
|
|
|
|
flagIssueLimit int
|
|
|
|
flagLabelsColorize bool
|
|
)
|
|
|
|
func init() {
|
|
cmdShowIssue.Flag.StringVarP(&flagShowIssueFormat, "format", "f", "", "FORMAT")
|
|
|
|
cmdCreateIssue.Flag.VarP(&flagIssueMessage, "message", "m", "MESSAGE")
|
|
cmdCreateIssue.Flag.StringVarP(&flagIssueFile, "file", "F", "", "FILE")
|
|
cmdCreateIssue.Flag.Uint64VarP(&flagIssueMilestone, "milestone", "M", 0, "MILESTONE")
|
|
cmdCreateIssue.Flag.VarP(&flagIssueLabels, "label", "l", "LABEL")
|
|
cmdCreateIssue.Flag.VarP(&flagIssueAssignees, "assign", "a", "ASSIGNEE")
|
|
cmdCreateIssue.Flag.BoolVarP(&flagIssueBrowse, "browse", "o", false, "BROWSE")
|
|
cmdCreateIssue.Flag.BoolVarP(&flagIssueCopy, "copy", "c", false, "COPY")
|
|
cmdCreateIssue.Flag.BoolVarP(&flagIssueEdit, "edit", "e", false, "EDIT")
|
|
|
|
cmdIssue.Flag.StringVarP(&flagIssueAssignee, "assignee", "a", "", "ASSIGNEE")
|
|
cmdIssue.Flag.StringVarP(&flagIssueState, "state", "s", "", "STATE")
|
|
cmdIssue.Flag.StringVarP(&flagIssueFormat, "format", "f", "%sC%>(8)%i%Creset %t% l%n", "FORMAT")
|
|
cmdIssue.Flag.StringVarP(&flagIssueMilestoneFilter, "milestone", "M", "", "MILESTONE")
|
|
cmdIssue.Flag.StringVarP(&flagIssueCreator, "creator", "c", "", "CREATOR")
|
|
cmdIssue.Flag.StringVarP(&flagIssueMentioned, "mentioned", "@", "", "USER")
|
|
cmdIssue.Flag.StringVarP(&flagIssueLabelsFilter, "labels", "l", "", "LABELS")
|
|
cmdIssue.Flag.StringVarP(&flagIssueSince, "since", "d", "", "DATE")
|
|
cmdIssue.Flag.StringVarP(&flagIssueSort, "sort", "o", "created", "SORT_KEY")
|
|
cmdIssue.Flag.BoolVarP(&flagIssueSortAscending, "sort-ascending", "^", false, "SORT_KEY")
|
|
cmdIssue.Flag.BoolVarP(&flagIssueIncludePulls, "include-pulls", "", false, "INCLUDE_PULLS")
|
|
cmdIssue.Flag.IntVarP(&flagIssueLimit, "limit", "L", -1, "LIMIT")
|
|
|
|
cmdLabel.Flag.BoolVarP(&flagLabelsColorize, "color", "", false, "COLORIZE")
|
|
|
|
cmdIssue.Use(cmdShowIssue)
|
|
cmdIssue.Use(cmdCreateIssue)
|
|
cmdIssue.Use(cmdLabel)
|
|
CmdRunner.Use(cmdIssue)
|
|
}
|
|
|
|
func listIssues(cmd *Command, args *Args) {
|
|
localRepo, err := github.LocalRepo()
|
|
utils.Check(err)
|
|
|
|
project, err := localRepo.MainProject()
|
|
utils.Check(err)
|
|
|
|
gh := github.NewClient(project.Host)
|
|
|
|
if args.Noop {
|
|
ui.Printf("Would request list of issues for %s\n", project)
|
|
} else {
|
|
flagFilters := map[string]string{
|
|
"state": flagIssueState,
|
|
"assignee": flagIssueAssignee,
|
|
"milestone": flagIssueMilestoneFilter,
|
|
"creator": flagIssueCreator,
|
|
"mentioned": flagIssueMentioned,
|
|
"labels": flagIssueLabelsFilter,
|
|
"sort": flagIssueSort,
|
|
}
|
|
filters := map[string]interface{}{}
|
|
for flag, filter := range flagFilters {
|
|
if cmd.FlagPassed(flag) {
|
|
filters[flag] = filter
|
|
}
|
|
}
|
|
|
|
if flagIssueSortAscending {
|
|
filters["direction"] = "asc"
|
|
} else {
|
|
filters["direction"] = "desc"
|
|
}
|
|
|
|
if cmd.FlagPassed("since") {
|
|
if sinceTime, err := time.ParseInLocation("2006-01-02", flagIssueSince, time.Local); err == nil {
|
|
filters["since"] = sinceTime.Format(time.RFC3339)
|
|
} else {
|
|
filters["since"] = flagIssueSince
|
|
}
|
|
}
|
|
|
|
issues, err := gh.FetchIssues(project, filters, flagIssueLimit, func(issue *github.Issue) bool {
|
|
return issue.PullRequest == nil || flagIssueIncludePulls
|
|
})
|
|
utils.Check(err)
|
|
|
|
maxNumWidth := 0
|
|
for _, issue := range issues {
|
|
if numWidth := len(strconv.Itoa(issue.Number)); numWidth > maxNumWidth {
|
|
maxNumWidth = numWidth
|
|
}
|
|
}
|
|
|
|
colorize := ui.IsTerminal(os.Stdout)
|
|
for _, issue := range issues {
|
|
ui.Print(formatIssue(issue, flagIssueFormat, colorize))
|
|
}
|
|
}
|
|
|
|
args.NoForward()
|
|
}
|
|
|
|
func formatIssuePlaceholders(issue github.Issue, colorize bool) map[string]string {
|
|
var stateColorSwitch string
|
|
if colorize {
|
|
issueColor := 32
|
|
if issue.State == "closed" {
|
|
issueColor = 31
|
|
}
|
|
stateColorSwitch = fmt.Sprintf("\033[%dm", issueColor)
|
|
}
|
|
|
|
var labelStrings []string
|
|
var rawLabels []string
|
|
for _, label := range issue.Labels {
|
|
if !colorize {
|
|
labelStrings = append(labelStrings, fmt.Sprintf(" %s ", label.Name))
|
|
continue
|
|
}
|
|
color, err := utils.NewColor(label.Color)
|
|
if err != nil {
|
|
utils.Check(err)
|
|
}
|
|
|
|
labelStrings = append(labelStrings, colorizeLabel(label, color))
|
|
rawLabels = append(rawLabels, label.Name)
|
|
}
|
|
|
|
var assignees []string
|
|
for _, assignee := range issue.Assignees {
|
|
assignees = append(assignees, assignee.Login)
|
|
}
|
|
|
|
var milestoneNumber, milestoneTitle string
|
|
if issue.Milestone != nil {
|
|
milestoneNumber = fmt.Sprintf("%d", issue.Milestone.Number)
|
|
milestoneTitle = issue.Milestone.Title
|
|
}
|
|
|
|
var numCommentsWrapped string
|
|
numComments := fmt.Sprintf("%d", issue.Comments)
|
|
if issue.Comments > 0 {
|
|
numCommentsWrapped = fmt.Sprintf("(%d)", issue.Comments)
|
|
}
|
|
|
|
var createdDate, createdAtISO8601, createdAtUnix, createdAtRelative,
|
|
updatedDate, updatedAtISO8601, updatedAtUnix, updatedAtRelative string
|
|
if !issue.CreatedAt.IsZero() {
|
|
createdDate = issue.CreatedAt.Format("02 Jan 2006")
|
|
createdAtISO8601 = issue.CreatedAt.Format(time.RFC3339)
|
|
createdAtUnix = fmt.Sprintf("%d", issue.CreatedAt.Unix())
|
|
createdAtRelative = utils.TimeAgo(issue.CreatedAt)
|
|
}
|
|
if !issue.UpdatedAt.IsZero() {
|
|
updatedDate = issue.UpdatedAt.Format("02 Jan 2006")
|
|
updatedAtISO8601 = issue.UpdatedAt.Format(time.RFC3339)
|
|
updatedAtUnix = fmt.Sprintf("%d", issue.UpdatedAt.Unix())
|
|
updatedAtRelative = utils.TimeAgo(issue.UpdatedAt)
|
|
}
|
|
|
|
return map[string]string{
|
|
"I": fmt.Sprintf("%d", issue.Number),
|
|
"i": fmt.Sprintf("#%d", issue.Number),
|
|
"U": issue.HtmlUrl,
|
|
"S": issue.State,
|
|
"sC": stateColorSwitch,
|
|
"t": issue.Title,
|
|
"l": strings.Join(labelStrings, " "),
|
|
"L": strings.Join(rawLabels, ", "),
|
|
"b": issue.Body,
|
|
"au": issue.User.Login,
|
|
"as": strings.Join(assignees, ", "),
|
|
"Mn": milestoneNumber,
|
|
"Mt": milestoneTitle,
|
|
"NC": numComments,
|
|
"Nc": numCommentsWrapped,
|
|
"cD": createdDate,
|
|
"cI": createdAtISO8601,
|
|
"ct": createdAtUnix,
|
|
"cr": createdAtRelative,
|
|
"uD": updatedDate,
|
|
"uI": updatedAtISO8601,
|
|
"ut": updatedAtUnix,
|
|
"ur": updatedAtRelative,
|
|
}
|
|
}
|
|
|
|
func formatPullRequestPlaceholders(pr github.PullRequest) map[string]string {
|
|
base := pr.Base.Ref
|
|
head := pr.Head.Label
|
|
if pr.IsSameRepo() {
|
|
head = pr.Head.Ref
|
|
}
|
|
|
|
var requestedReviewers []string
|
|
for _, requestedReviewer := range pr.RequestedReviewers {
|
|
requestedReviewers = append(requestedReviewers, requestedReviewer.Login)
|
|
}
|
|
for _, requestedTeam := range pr.RequestedTeams {
|
|
teamSlug := fmt.Sprintf("%s/%s", pr.Base.Repo.Owner.Login, requestedTeam.Slug)
|
|
requestedReviewers = append(requestedReviewers, teamSlug)
|
|
}
|
|
|
|
var mergedDate, mergedAtISO8601, mergedAtUnix, mergedAtRelative string
|
|
if !pr.MergedAt.IsZero() {
|
|
mergedDate = pr.MergedAt.Format("02 Jan 2006")
|
|
mergedAtISO8601 = pr.MergedAt.Format(time.RFC3339)
|
|
mergedAtUnix = fmt.Sprintf("%d", pr.MergedAt.Unix())
|
|
mergedAtRelative = utils.TimeAgo(pr.MergedAt)
|
|
}
|
|
|
|
return map[string]string{
|
|
"B": base,
|
|
"H": head,
|
|
"sB": pr.Base.Sha,
|
|
"sH": pr.Head.Sha,
|
|
"sm": pr.MergeCommitSha,
|
|
"rs": strings.Join(requestedReviewers, ", "),
|
|
"mD": mergedDate,
|
|
"mI": mergedAtISO8601,
|
|
"mt": mergedAtUnix,
|
|
"mr": mergedAtRelative,
|
|
}
|
|
}
|
|
|
|
func formatIssue(issue github.Issue, format string, colorize bool) string {
|
|
placeholders := formatIssuePlaceholders(issue, colorize)
|
|
return ui.Expand(format, placeholders, colorize)
|
|
}
|
|
|
|
func showIssue(cmd *Command, args *Args) {
|
|
issueNumber := cmd.Arg(0)
|
|
if issueNumber == "" {
|
|
utils.Check(fmt.Errorf(cmd.Synopsis()))
|
|
}
|
|
|
|
localRepo, err := github.LocalRepo()
|
|
utils.Check(err)
|
|
|
|
project, err := localRepo.MainProject()
|
|
utils.Check(err)
|
|
|
|
gh := github.NewClient(project.Host)
|
|
|
|
var issue = &github.Issue{}
|
|
issue, err = gh.FetchIssue(project, issueNumber)
|
|
utils.Check(err)
|
|
|
|
args.NoForward()
|
|
|
|
colorize := ui.IsTerminal(os.Stdout)
|
|
if flagShowIssueFormat != "" {
|
|
ui.Print(formatIssue(*issue, flagShowIssueFormat, colorize))
|
|
return
|
|
}
|
|
|
|
var closed = ""
|
|
if issue.State != "open" {
|
|
closed = "[CLOSED] "
|
|
}
|
|
commentsList, err := gh.FetchComments(project, issueNumber)
|
|
utils.Check(err)
|
|
|
|
ui.Printf("# %s%s\n\n", closed, issue.Title)
|
|
ui.Printf("* created by @%s on %s\n", issue.User.Login, issue.CreatedAt.String())
|
|
|
|
if len(issue.Assignees) > 0 {
|
|
var assignees []string
|
|
for _, user := range issue.Assignees {
|
|
assignees = append(assignees, user.Login)
|
|
}
|
|
ui.Printf("* assignees: %s\n", strings.Join(assignees, ", "))
|
|
}
|
|
|
|
ui.Printf("\n%s\n", issue.Body)
|
|
|
|
if issue.Comments > 0 {
|
|
ui.Printf("\n## Comments:\n")
|
|
for _, comment := range commentsList {
|
|
ui.Printf("\n### comment by @%s on %s\n\n%s\n", comment.User.Login, comment.CreatedAt.String(), comment.Body)
|
|
}
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
func createIssue(cmd *Command, args *Args) {
|
|
localRepo, err := github.LocalRepo()
|
|
utils.Check(err)
|
|
|
|
project, err := localRepo.MainProject()
|
|
utils.Check(err)
|
|
|
|
gh := github.NewClient(project.Host)
|
|
|
|
messageBuilder := &github.MessageBuilder{
|
|
Filename: "ISSUE_EDITMSG",
|
|
Title: "issue",
|
|
}
|
|
|
|
messageBuilder.AddCommentedSection(fmt.Sprintf(`Creating an issue for %s
|
|
|
|
Write a message for this issue. The first block of
|
|
text is the title and the rest is the description.`, project))
|
|
|
|
if len(flagIssueMessage) > 0 {
|
|
messageBuilder.Message = flagIssueMessage.String()
|
|
messageBuilder.Edit = flagIssueEdit
|
|
} else if cmd.FlagPassed("file") {
|
|
messageBuilder.Message, err = msgFromFile(flagIssueFile)
|
|
utils.Check(err)
|
|
messageBuilder.Edit = flagIssueEdit
|
|
} else {
|
|
messageBuilder.Edit = true
|
|
|
|
workdir, _ := git.WorkdirName()
|
|
if workdir != "" {
|
|
template, err := github.ReadTemplate(github.IssueTemplate, workdir)
|
|
utils.Check(err)
|
|
if template != "" {
|
|
messageBuilder.Message = template
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
title, body, err := messageBuilder.Extract()
|
|
utils.Check(err)
|
|
|
|
if title == "" {
|
|
utils.Check(fmt.Errorf("Aborting creation due to empty issue title"))
|
|
}
|
|
|
|
params := map[string]interface{}{
|
|
"title": title,
|
|
"body": body,
|
|
}
|
|
|
|
if len(flagIssueLabels) > 0 {
|
|
params["labels"] = flagIssueLabels
|
|
}
|
|
|
|
if len(flagIssueAssignees) > 0 {
|
|
params["assignees"] = flagIssueAssignees
|
|
}
|
|
|
|
if flagIssueMilestone > 0 {
|
|
params["milestone"] = flagIssueMilestone
|
|
}
|
|
|
|
args.NoForward()
|
|
if args.Noop {
|
|
ui.Printf("Would create issue `%s' for %s\n", params["title"], project)
|
|
} else {
|
|
issue, err := gh.CreateIssue(project, params)
|
|
utils.Check(err)
|
|
|
|
printBrowseOrCopy(args, issue.HtmlUrl, flagIssueBrowse, flagIssueCopy)
|
|
}
|
|
|
|
messageBuilder.Cleanup()
|
|
}
|
|
|
|
func listLabels(cmd *Command, args *Args) {
|
|
localRepo, err := github.LocalRepo()
|
|
utils.Check(err)
|
|
|
|
project, err := localRepo.MainProject()
|
|
utils.Check(err)
|
|
|
|
gh := github.NewClient(project.Host)
|
|
|
|
args.NoForward()
|
|
if args.Noop {
|
|
ui.Printf("Would request list of labels for %s\n", project)
|
|
return
|
|
}
|
|
|
|
labels, err := gh.FetchLabels(project)
|
|
utils.Check(err)
|
|
|
|
for _, label := range labels {
|
|
ui.Print(formatLabel(label, flagLabelsColorize))
|
|
}
|
|
}
|
|
|
|
func formatLabel(label github.IssueLabel, colorize bool) string {
|
|
if colorize {
|
|
if color, err := utils.NewColor(label.Color); err == nil {
|
|
return fmt.Sprintf("%s\n", colorizeLabel(label, color))
|
|
}
|
|
}
|
|
return fmt.Sprintf("%s\n", label.Name)
|
|
}
|
|
|
|
func colorizeLabel(label github.IssueLabel, color *utils.Color) string {
|
|
return fmt.Sprintf("\033[38;5;%d;48;2;%d;%d;%dm %s \033[m",
|
|
getSuitableLabelTextColor(color), color.Red, color.Green, color.Blue, label.Name)
|
|
}
|
|
|
|
func getSuitableLabelTextColor(color *utils.Color) int {
|
|
if color.Brightness() < 0.65 {
|
|
return 15 // white text
|
|
}
|
|
return 16 // black text
|
|
}
|