Show Issues 2.0 fields in non-tty view output

Add issue-type, parent, sub-issues, sub-issues-completed, blocked-by,
and blocking lines to the raw issue preview. Empty values still print
to keep line counts stable for head|grep workflows.

Pull the issue-ref formatting into a small set of helpers so the human
and machine renderers share a single owner/repo#N source of truth.
formatLinkedIssueRef no longer takes baseRepo: callers in this package
always have repository.nameWithOwner on the LinkedIssue, so the
disambiguation between same-repo and cross-repo references is no
longer needed and the resulting refs are unambiguous.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Kynan Ware 2026-05-12 14:06:23 -06:00
parent d1363a9e5b
commit 1a2293b6e0
2 changed files with 127 additions and 14 deletions

View file

@ -213,6 +213,24 @@ func printRawIssuePreview(out io.Writer, issue *api.Issue) error {
milestoneTitle = issue.Milestone.Title
}
fmt.Fprintf(out, "milestone:\t%s\n", milestoneTitle)
var issueTypeName string
if issue.IssueType != nil {
issueTypeName = issue.IssueType.Name
}
fmt.Fprintf(out, "issue-type:\t%s\n", issueTypeName)
var parentRef string
if issue.Parent != nil {
parentRef = formatLinkedIssueRef(issue.Parent)
}
fmt.Fprintf(out, "parent:\t%s\n", parentRef)
fmt.Fprintf(out, "sub-issues:\t%s\n", formatLinkedIssueRefs(issue.SubIssues.Nodes))
var subIssuesCompleted string
if issue.SubIssuesSummary.Total > 0 {
subIssuesCompleted = fmt.Sprintf("%d/%d", issue.SubIssuesSummary.Completed, issue.SubIssuesSummary.Total)
}
fmt.Fprintf(out, "sub-issues-completed:\t%s\n", subIssuesCompleted)
fmt.Fprintf(out, "blocked-by:\t%s\n", formatLinkedIssueRefs(issue.BlockedBy.Nodes))
fmt.Fprintf(out, "blocking:\t%s\n", formatLinkedIssueRefs(issue.Blocking.Nodes))
fmt.Fprintf(out, "number:\t%d\n", issue.Number)
fmt.Fprintln(out, "--")
fmt.Fprintln(out, issue.Body)
@ -260,13 +278,13 @@ func printHumanIssuePreview(opts *ViewOptions, baseRepo ghrepo.Interface, issue
}
if issue.Parent != nil {
fmt.Fprint(out, cs.Bold("Parent: "))
fmt.Fprintln(out, formatLinkedIssueRef(baseRepo, issue.Parent)+" "+issue.Parent.Title)
fmt.Fprintln(out, formatLinkedIssueRef(issue.Parent)+" "+issue.Parent.Title)
}
if blockedBy := formatLinkedIssueList(baseRepo, issue.BlockedBy.Nodes); blockedBy != "" {
if blockedBy := formatLinkedIssueListWithTitle(issue.BlockedBy.Nodes); blockedBy != "" {
fmt.Fprint(out, cs.Bold("Blocked by: "))
fmt.Fprintln(out, blockedBy)
}
if blocking := formatLinkedIssueList(baseRepo, issue.Blocking.Nodes); blocking != "" {
if blocking := formatLinkedIssueListWithTitle(issue.Blocking.Nodes); blocking != "" {
fmt.Fprint(out, cs.Bold("Blocking: "))
fmt.Fprintln(out, blocking)
}
@ -311,7 +329,7 @@ func printHumanIssuePreview(opts *ViewOptions, baseRepo ghrepo.Interface, issue
}
fmt.Fprintf(out, "%s %s %s\n",
stateColor(stateLabel),
formatLinkedIssueRef(baseRepo, &sub),
formatLinkedIssueRef(&sub),
sub.Title,
)
}
@ -335,23 +353,32 @@ func printHumanIssuePreview(opts *ViewOptions, baseRepo ghrepo.Interface, issue
}
// formatLinkedIssueRef formats an issue reference as owner/repo#N.
// Cross-repo references use the issue's own repository; same-repo
// references use the base repo name for consistency.
func formatLinkedIssueRef(baseRepo ghrepo.Interface, issue *api.LinkedIssue) string {
if issue.Repository.NameWithOwner != "" && issue.Repository.NameWithOwner != ghrepo.FullName(baseRepo) {
return fmt.Sprintf("%s#%d", issue.Repository.NameWithOwner, issue.Number)
}
return fmt.Sprintf("%s#%d", ghrepo.FullName(baseRepo), issue.Number)
func formatLinkedIssueRef(issue *api.LinkedIssue) string {
return fmt.Sprintf("%s#%d", issue.Repository.NameWithOwner, issue.Number)
}
// formatLinkedIssueList formats a comma-separated list of linked issue references with titles.
func formatLinkedIssueList(baseRepo ghrepo.Interface, issues []api.LinkedIssue) string {
// formatLinkedIssueRefs formats a comma-separated list of linked issue
// references without titles.
func formatLinkedIssueRefs(issues []api.LinkedIssue) string {
return joinLinkedIssues(issues, false)
}
// formatLinkedIssueListWithTitle formats a comma-separated list of linked
// issue references with each title appended after the reference.
func formatLinkedIssueListWithTitle(issues []api.LinkedIssue) string {
return joinLinkedIssues(issues, true)
}
func joinLinkedIssues(issues []api.LinkedIssue, withTitle bool) string {
if len(issues) == 0 {
return ""
}
parts := make([]string, len(issues))
for i, issue := range issues {
parts[i] = formatLinkedIssueRef(baseRepo, &issue) + " " + issue.Title
parts[i] = formatLinkedIssueRef(&issue)
if withTitle {
parts[i] += " " + issue.Title
}
}
return strings.Join(parts, ", ")
}

View file

@ -781,6 +781,48 @@ func TestIssueView_tty_Issues2AllFields(t *testing.T) {
assert.Contains(t, out, "View this issue on GitHub: https://github.com/OWNER/REPO/issues/123")
}
func TestIssueView_nontty_Issues2AllFields(t *testing.T) {
ios, _, stdout, stderr := iostreams.Test()
httpReg := &httpmock.Registry{}
defer httpReg.Verify(t)
httpReg.Register(
httpmock.GraphQL(`query IssueByNumber\b`),
httpmock.StringResponse(issueResponseAllIssues2Fields()),
)
mockEmptyV2ProjectItems(t, httpReg)
opts := ViewOptions{
IO: ios,
Now: func() time.Time {
t, _ := time.Parse(time.RFC822, "03 Nov 24 15:04 UTC")
return t
},
HttpClient: func() (*http.Client, error) {
return &http.Client{Transport: httpReg}, nil
},
BaseRepo: func() (ghrepo.Interface, error) {
return ghrepo.New("OWNER", "REPO"), nil
},
IssueNumber: 123,
}
err := viewRun(&opts)
require.NoError(t, err)
assert.Equal(t, "", stderr.String())
out := stdout.String()
assert.Contains(t, out, "issue-type:\tBug\n")
assert.Contains(t, out, "parent:\tOWNER/REPO#100\n")
assert.Contains(t, out, "sub-issues:\tOWNER/REPO#101, OWNER/REPO#102\n")
assert.Contains(t, out, "sub-issues-completed:\t1/2\n")
assert.Contains(t, out, "blocked-by:\tOWNER/REPO#200\n")
assert.Contains(t, out, "blocking:\tOWNER/REPO#300\n")
}
func TestIssueView_tty_Issues2NoFields(t *testing.T) {
ios, _, stdout, stderr := iostreams.Test()
ios.SetStdoutTTY(true)
@ -833,6 +875,50 @@ func TestIssueView_tty_Issues2NoFields(t *testing.T) {
assert.NotContains(t, out, "Sub-issues")
}
func TestIssueView_nontty_Issues2NoFields(t *testing.T) {
ios, _, stdout, stderr := iostreams.Test()
httpReg := &httpmock.Registry{}
defer httpReg.Verify(t)
httpReg.Register(
httpmock.GraphQL(`query IssueByNumber\b`),
httpmock.StringResponse(issueResponseNoIssues2Fields()),
)
mockEmptyV2ProjectItems(t, httpReg)
opts := ViewOptions{
IO: ios,
Now: func() time.Time {
t, _ := time.Parse(time.RFC822, "03 Nov 24 15:04 UTC")
return t
},
HttpClient: func() (*http.Client, error) {
return &http.Client{Transport: httpReg}, nil
},
BaseRepo: func() (ghrepo.Interface, error) {
return ghrepo.New("OWNER", "REPO"), nil
},
IssueNumber: 456,
}
err := viewRun(&opts)
require.NoError(t, err)
assert.Equal(t, "", stderr.String())
out := stdout.String()
// Issues 2.0 keys appear with empty values to keep line counts stable
// for `head | grep` workflows.
assert.Contains(t, out, "issue-type:\t\n")
assert.Contains(t, out, "parent:\t\n")
assert.Contains(t, out, "sub-issues:\t\n")
assert.Contains(t, out, "sub-issues-completed:\t\n")
assert.Contains(t, out, "blocked-by:\t\n")
assert.Contains(t, out, "blocking:\t\n")
}
func TestIssueView_json_IssueType(t *testing.T) {
httpReg := &httpmock.Registry{}
defer httpReg.Verify(t)