Repository: cli/cli
Pull request #7725 (2023-07-18 19:34:44)

Fix pr checks command for GHES versions older than 3.9
My previous change https://github.com/cli/cli/pull/7618 did not take into account that the `event` field does not exist in GHES older than 3.9 and thus broke the `pr checks` command for older GHES instances. This PR fixes that by using feature detection to see if the `event` field exists before requesting it.

Fixes https://github.com/cli/cli/issues/7716


Patch:
diff --git a/api/query_builder.go b/api/query_builder.go
index b74f9519b..bd2d2ece5 100644
--- a/api/query_builder.go
+++ b/api/query_builder.go
@@ -205,11 +205,15 @@ func StatusCheckRollupGraphQLWithoutCountByState(after string) string {
 	}`), afterClause)
 }
 
-func RequiredStatusCheckRollupGraphQL(prID, after string) string {
+func RequiredStatusCheckRollupGraphQL(prID, after string, includeEvent bool) string {
 	var afterClause string
 	if after != "" {
 		afterClause = ",after:" + after
 	}
+	eventField := "event,"
+	if !includeEvent {
+		eventField = ""
+	}
 	return fmt.Sprintf(shortenQuery(`
 	statusCheckRollup: commits(last: 1) {
 		nodes {
@@ -228,7 +232,7 @@ func RequiredStatusCheckRollupGraphQL(prID, after string) string {
 							},
 							...on CheckRun {
 								name,
-								checkSuite{workflowRun{event,workflow{name}}},
+								checkSuite{workflowRun{%[3]sworkflow{name}}},
 								status,
 								conclusion,
 								startedAt,
@@ -242,7 +246,7 @@ func RequiredStatusCheckRollupGraphQL(prID, after string) string {
 				}
 			}
 		}
-	}`), afterClause, prID)
+	}`), afterClause, prID, eventField)
 }
 
 var IssueFields = []string{
diff --git a/api/sanitize_ascii.go b/api/sanitize_ascii.go
index 75cb3062b..d5d253452 100644
--- a/api/sanitize_ascii.go
+++ b/api/sanitize_ascii.go
@@ -1,12 +1,11 @@
 package api
 
 import (
-	"bytes"
 	"io"
 	"net/http"
 	"regexp"
-	"strings"
 
+	"github.com/cli/cli/v2/internal/asciisanitizer"
 	"golang.org/x/text/transform"
 )
 
@@ -14,8 +13,6 @@ var jsonTypeRE = regexp.MustCompile(`[/+]json($|;)`)
 
 // GitHub servers do not sanitize their API output for terminal display
 // and leave in unescaped ASCII control characters.
-// C0 control characters are represented in their unicode code point form ranging from \u0000 to \u001F.
-// C1 control characters are represented in two bytes, the first being 0xC2 and the second ranging from 0x80 to 0x9F.
 // These control characters will be interpreted by the terminal, this behaviour can be
 // used maliciously as an attack vector, especially the control characters \u001B and \u009B.
 // This function wraps JSON response bodies in a ReadCloser that transforms C0 and C1
@@ -37,187 +34,7 @@ func sanitizedReadCloser(rc io.ReadCloser) io.ReadCloser {
 		io.Reader
 		io.Closer
 	}{
-		Reader: transform.NewReader(rc, &sanitizer{}),
+		Reader: transform.NewReader(rc, &asciisanitizer.Sanitizer{}),
 		Closer: rc,
 	}
 }
-
-// Sanitizer implements transform.Transformer interface.
-type sanitizer struct {
-	addEscape bool
-}
-
-// Transform uses a sliding window algorithm to detect C0 and C1
-// ASCII control sequences as they are read and replaces them
-// with equivalent inert characters. Characters that are not part
-// of a control sequence are not modified.
-func (t *sanitizer) Transform(dst, src []byte, atEOF bool) (nDst, nSrc int, err error) {
-	lSrc := len(src)
-	lDst := len(dst)
-
-	for nSrc < lSrc-6 && nDst < lDst {
-		window := src[nSrc : nSrc+6]
-
-		// Replace C1 Control Characters
-		if repl, found := mapC1ToCaret(window[:2]); found {
-			if len(repl)+nDst > lDst {
-				err = transform.ErrShortDst
-				return
-			}
-			for j := 0; j < len(repl); j++ {
-				dst[nDst] = repl[j]
-				nDst++
-			}
-			nSrc += 2
-			continue
-		}
-
-		// Replace C0 Control Characters
-		if repl, found := mapC0ToCaret(window); found {
-			if t.addEscape {
-				repl = append([]byte{'\\'}, repl...)
-			}
-			if len(repl)+nDst > lDst {
-				err = transform.ErrShortDst
-				return
-			}
-			for j := 0; j < len(repl); j++ {
-				dst[nDst] = repl[j]
-				nDst++
-			}
-			t.addEscape = false
-			nSrc += 6
-			continue
-		}
-
-		if window[0] == '\\' {
-			t.addEscape = !t.addEscape
-		} else {
-			t.addEscape = false
-		}
-
-		dst[nDst] = src[nSrc]
-		nDst++
-		nSrc++
-	}
-
-	if !atEOF {
-		err = transform.ErrShortSrc
-		return
-	}
-
-	remaining := lSrc - nSrc
-	if remaining+nDst > lDst {
-		err = transform.ErrShortDst
-		return
-	}
-
-	for j := 0; j < remaining; j++ {
-		dst[nDst] = src[nSrc]
-		nDst++
-		nSrc++
-	}
-
-	return
-}
-
-func (t *sanitizer) Reset() {
-	t.addEscape = false
-}
-
-// mapC0ToCaret maps C0 control sequences to caret notation.
-func mapC0ToCaret(b []byte) ([]byte, bool) {
-	if len(b) != 6 {
-		return b, false
-	}
-	if !bytes.HasPrefix(b, []byte(`\u00`)) {
-		return b, false
-	}
-	m := map[string]string{
-		`\u0000`: `^@`,
-		`\u0001`: `^A`,
-		`\u0002`: `^B`,
-		`\u0003`: `^C`,
-		`\u0004`: `^D`,
-		`\u0005`: `^E`,
-		`\u0006`: `^F`,
-		`\u0007`: `^G`,
-		`\u0008`: `^H`,
-		`\u0009`: `^I`,
-		`\u000a`: `^J`,
-		`\u000b`: `^K`,
-		`\u000c`: `^L`,
-		`\u000d`: `^M`,
-		`\u000e`: `^N`,
-		`\u000f`: `^O`,
-		`\u0010`: `^P`,
-		`\u0011`: `^Q`,
-		`\u0012`: `^R`,
-		`\u0013`: `^S`,
-		`\u0014`: `^T`,
-		`\u0015`: `^U`,
-		`\u0016`: `^V`,
-		`\u0017`: `^W`,
-		`\u0018`: `^X`,
-		`\u0019`: `^Y`,
-		`\u001a`: `^Z`,
-		`\u001b`: `^[`,
-		`\u001c`: `^\\`,
-		`\u001d`: `^]`,
-		`\u001e`: `^^`,
-		`\u001f`: `^_`,
-	}
-	if c, ok := m[strings.ToLower(string(b))]; ok {
-		return []byte(c), true
-	}
-	return b, false
-}
-
-// mapC1ToCaret maps C1 control sequences to caret notation.
-// C1 control sequences are two bytes long where the first byte is 0xC2.
-func mapC1ToCaret(b []byte) ([]byte, bool) {
-	if len(b) != 2 {
-		return b, false
-	}
-	if b[0] != 0xC2 {
-		return b, false
-	}
-	m := map[byte]string{
-		128: `^@`,
-		129: `^A`,
-		130: `^B`,
-		131: `^C`,
-		132: `^D`,
-		133: `^E`,
-		134: `^F`,
-		135: `^G`,
-		136: `^H`,
-		137: `^I`,
-		138: `^J`,
-		139: `^K`,
-		140: `^L`,
-		141: `^M`,
-		142: `^N`,
-		143: `^O`,
-		144: `^P`,
-		145: `^Q`,
-		146: `^R`,
-		147: `^S`,
-		148: `^T`,
-		149: `^U`,
-		150: `^V`,
-		151: `^W`,
-		152: `^X`,
-		153: `^Y`,
-		154: `^Z`,
-		155: `^[`,
-		156: `^\\`,
-		157: `^]`,
-		158: `^^`,
-		159: `^_`,
-	}
-	if c, ok := m[b[1]]; ok {
-		return []byte(c), true
-	}
-	return b, false
-}
diff --git a/internal/asciisanitizer/sanitizer.go b/internal/asciisanitizer/sanitizer.go
new file mode 100644
index 000000000..8c169f22b
--- /dev/null
+++ b/internal/asciisanitizer/sanitizer.go
@@ -0,0 +1,220 @@
+// Package asciisanitizer implements an ASCII control character sanitizer for GitHub API responses so they can be
+// safely displayed in the terminal. The GitHub API does not sanitize their responses for terminal display and will
+// leave in unescaped ASCII control characters. These ASCII control characters will be interpreted by the terminal,
+// this behaviour can be used maliciously as an attack vector, especially the ASCII control characters \u001B and \u009B.
+package asciisanitizer
+
+import (
+	"bytes"
+	"errors"
+	"strings"
+	"unicode"
+	"unicode/utf8"
+
+	"golang.org/x/text/transform"
+)
+
+// Sanitizer implements transform.Transformer interface.
+type Sanitizer struct {
+	addEscape bool
+}
+
+// Transform uses a sliding window algorithm to detect C0 and C1 control characters as they are read and replaces
+// them with equivalent inert characters. Bytes that are not part of a control character are not modified.
+// C0 control characters can be either encoded or unencoded. C1 control characters are not encoded.
+// Encoded C0 control characters are six byte strings, representing the unicode code point, ranging from \u0000 to \u001F.
+func (t *Sanitizer) Transform(dst, src []byte, atEOF bool) (nDst, nSrc int, err error) {
+	transfer := func(write, read []byte) error {
+		readLength := len(read)
+		writeLength := len(write)
+		if writeLength > len(dst) {
+			return transform.ErrShortDst
+		}
+		copy(dst, write)
+		nDst += writeLength
+		dst = dst[writeLength:]
+		nSrc += readLength
+		src = src[readLength:]
+		return nil
+	}
+
+	for len(src) > 0 {
+		// Make sure we always have 6 bytes if there are 6 bytes available.
+		// This is necessary for encoded C0 control characters.
+		if len(src) < 6 && !atEOF {
+			err = transform.ErrShortSrc
+			return
+		}
+		// Only support UTF-8 encoded data.
+		r, size := utf8.DecodeRune(src)
+		if r == utf8.RuneError {
+			if !atEOF {
+				err = transform.ErrShortSrc
+				return
+			} else {
+				err = errors.New("invalid UTF-8 string")
+				return
+			}
+		}
+		// Replace C0 and C1 control characters.
+		if unicode.IsControl(r) {
+			if repl, found := mapControlToCaret(r); found {
+				err = transfer(repl, src[:size])
+				if err != nil {
+					return
+				}
+				continue
+			}
+		}
+		// Replace encoded C0 control characters.
+		if len(src) >= 6 {
+			if repl, found := mapEncodedControlToCaret(src[:6]); found {
+				if t.addEscape {
+					// Add an escape character when necessary to prevent creating
+					// invalid JSON with our replacements.
+					repl = append([]byte{'\\'}, repl...)
+				}
+				err = transfer(repl, src[:6])
+				if err != nil {
+					return
+				}
+				continue
+			}
+		}
+		err = transfer(src[:size], src[:size])
+		if err != nil {
+			return
+		}
+		if r == '\\' {
+			t.addEscape = !t.addEscape
+		} else {
+			t.addEscape = false
+		}
+	}
+	return
+}
+
+// Reset resets the state and allows the Sanitizer to be reused.
+func (t *Sanitizer) Reset() {
+	t.addEscape = false
+}
+
+// mapControlToCaret maps C0 and C1 control characters to their caret notation.
+func mapControlToCaret(r rune) ([]byte, bool) {
+	//\t (09), \n (10), \v (11), \r (13) are valid C0 characters and are not sanitized.
+	m := map[rune]string{
+		0:   `^@`,
+		1:   `^A`,
+		2:   `^B`,
+		3:   `^C`,
+		4:   `^D`,
+		5:   `^E`,
+		6:   `^F`,
+		7:   `^G`,
+		8:   `^H`,
+		12:  `^L`,
+		14:  `^N`,
+		15:  `^O`,
+		16:  `^P`,
+		17:  `^Q`,
+		18:  `^R`,
+		19:  `^S`,
+		20:  `^T`,
+		21:  `^U`,
+		22:  `^V`,
+		23:  `^W`,
+		24:  `^X`,
+		25:  `^Y`,
+		26:  `^Z`,
+		27:  `^[`,
+		28:  `^\\`,
+		29:  `^]`,
+		30:  `^^`,
+		31:  `^_`,
+		128: `^@`,
+		129: `^A`,
+		130: `^B`,
+		131: `^C`,
+		132: `^D`,
+		133: `^E`,
+		134: `^F`,
+		135: `^G`,
+		136: `^H`,
+		137: `^I`,
+		138: `^J`,
+		139: `^K`,
+		140: `^L`,
+		141: `^M`,
+		142: `^N`,
+		143: `^O`,
+		144: `^P`,
+		145: `^Q`,
+		146: `^R`,
+		147: `^S`,
+		148: `^T`,
+		149: `^U`,
+		150: `^V`,
+		151: `^W`,
+		152: `^X`,
+		153: `^Y`,
+		154: `^Z`,
+		155: `^[`,
+		156: `^\\`,
+		157: `^]`,
+		158: `^^`,
+		159: `^_`,
+	}
+	if c, ok := m[r]; ok {
+		return []byte(c), true
+	}
+	return nil, false
+}
+
+// mapEncodedControlToCaret maps encoded C0 control characters to their caret notation.
+// Encoded C0 control characters are six byte strings, representing the unicode code point, ranging from \u0000 to \u001F.
+func mapEncodedControlToCaret(b []byte) ([]byte, bool) {
+	if len(b) != 6 {
+		return nil, false
+	}
+	if !bytes.HasPrefix(b, []byte(`\u00`)) {
+		return nil, false
+	}
+	m := map[string]string{
+		`\u0000`: `^@`,
+		`\u0001`: `^A`,
+		`\u0002`: `^B`,
+		`\u0003`: `^C`,
+		`\u0004`: `^D`,
+		`\u0005`: `^E`,
+		`\u0006`: `^F`,
+		`\u0007`: `^G`,
+		`\u0008`: `^H`,
+		`\u0009`: `^I`,
+		`\u000a`: `^J`,
+		`\u000b`: `^K`,
+		`\u000c`: `^L`,
+		`\u000d`: `^M`,
+		`\u000e`: `^N`,
+		`\u000f`: `^O`,
+		`\u0010`: `^P`,
+		`\u0011`: `^Q`,
+		`\u0012`: `^R`,
+		`\u0013`: `^S`,
+		`\u0014`: `^T`,
+		`\u0015`: `^U`,
+		`\u0016`: `^V`,
+		`\u0017`: `^W`,
+		`\u0018`: `^X`,
+		`\u0019`: `^Y`,
+		`\u001a`: `^Z`,
+		`\u001b`: `^[`,
+		`\u001c`: `^\\`,
+		`\u001d`: `^]`,
+		`\u001e`: `^^`,
+		`\u001f`: `^_`,
+	}
+	if c, ok := m[strings.ToLower(string(b))]; ok {
+		return []byte(c), true
+	}
+	return nil, false
+}
diff --git a/internal/featuredetection/feature_detection.go b/internal/featuredetection/feature_detection.go
index 983784804..d289d025f 100644
--- a/internal/featuredetection/feature_detection.go
+++ b/internal/featuredetection/feature_detection.go
@@ -5,6 +5,7 @@ import (
 
 	"github.com/cli/cli/v2/api"
 	"github.com/cli/cli/v2/internal/ghinstance"
+	"golang.org/x/sync/errgroup"
 )
 
 type Detector interface {
@@ -27,11 +28,13 @@ type PullRequestFeatures struct {
 	// the checkRunCount, checkRunCountsByState, statusContextCount and stausContextCountsByState
 	// fields on the StatusCheckRollupContextConnection
 	CheckRunAndStatusContextCounts bool
+	CheckRunEvent                  bool
 }
 
 var allPullRequestFeatures = PullRequestFeatures{
 	MergeQueue:                     true,
 	CheckRunAndStatusContextCounts: true,
+	CheckRunEvent:                  true,
 }
 
 type RepositoryFeatures struct {
@@ -111,8 +114,26 @@ func (d *detector) PullRequestFeatures() (PullRequestFeatures, error) {
 		} `graphql:"StatusCheckRollupContextConnection: __type(name: \"StatusCheckRollupContextConnection\")"`
 	}
 
+	// Break feature detection down into two separate queries because the platform
+	// only supports two `__type` expressions in one query.
+	var pullRequestFeatureDetection2 struct {
+		WorkflowRun struct {
+			Fields []struct {
+				Name string
+			} `graphql:"fields(includeDeprecated: true)"`
+		} `graphql:"WorkflowRun: __type(name: \"WorkflowRun\")"`
+	}
+
 	gql := api.NewClientFromHTTP(d.httpClient)
-	if err := gql.Query(d.host, "PullRequest_fields", &pullRequestFeatureDetection, nil); err != nil {
+
+	var wg errgroup.Group
+	wg.Go(func() error {
+		return gql.Query(d.host, "PullRequest_fields", &pullRequestFeatureDetection, nil)
+	})
+	wg.Go(func() error {
+		return gql.Query(d.host, "PullRequest_fields2", &pullRequestFeatureDetection2, nil)
+	})
+	if err := wg.Wait(); err != nil {
 		return PullRequestFeatures{}, err
 	}
 
@@ -132,6 +153,12 @@ func (d *detector) PullRequestFeatures() (PullRequestFeatures, error) {
 		}
 	}
 
+	for _, field := range pullRequestFeatureDetection2.WorkflowRun.Fields {
+		if field.Name == "event" {
+			features.CheckRunEvent = true
+		}
+	}
+
 	return features, nil
 }
 
diff --git a/pkg/cmd/pr/checks/checks.go b/pkg/cmd/pr/checks/checks.go
index fb629d497..6cd47cc98 100644
--- a/pkg/cmd/pr/checks/checks.go
+++ b/pkg/cmd/pr/checks/checks.go
@@ -9,6 +9,7 @@ import (
 	"github.com/MakeNowJust/heredoc"
 	"github.com/cli/cli/v2/api"
 	"github.com/cli/cli/v2/internal/browser"
+	fd "github.com/cli/cli/v2/internal/featuredetection"
 	"github.com/cli/cli/v2/internal/ghrepo"
 	"github.com/cli/cli/v2/internal/text"
 	"github.com/cli/cli/v2/pkg/cmd/pr/shared"
@@ -24,7 +25,8 @@ type ChecksOptions struct {
 	IO         *iostreams.IOStreams
 	Browser    browser.Browser
 
-	Finder shared.PRFinder
+	Finder   shared.PRFinder
+	Detector fd.Detector
 
 	SelectorArg string
 	WebMode     bool
@@ -142,8 +144,19 @@ func checksRun(opts *ChecksOptions) error {
 	var checks []check
 	var counts checkCounts
 	var err error
+	var includeEvent bool
 
-	checks, counts, err = populateStatusChecks(client, repo, pr, opts.Required)
+	if opts.Detector == nil {
+		cachedClient := api.NewCachedHTTPClient(client, time.Hour*24)
+		opts.Detector = fd.NewDetector(cachedClient, repo.RepoHost())
+	}
+	if features, featuresErr := opts.Detector.PullRequestFeatures(); featuresErr != nil {
+		return featuresErr
+	} else {
+		includeEvent = features.CheckRunEvent
+	}
+
+	checks, counts, err = populateStatusChecks(client, repo, pr, opts.Required, includeEvent)
 	if err != nil {
 		return err
 	}
@@ -183,7 +196,7 @@ func checksRun(opts *ChecksOptions) error {
 
 		time.Sleep(opts.Interval)
 
-		checks, counts, err = populateStatusChecks(client, repo, pr, opts.Required)
+		checks, counts, err = populateStatusChecks(client, repo, pr, opts.Required, includeEvent)
 		if err != nil {
 			break
 		}
@@ -210,7 +223,7 @@ func checksRun(opts *ChecksOptions) error {
 	return nil
 }
 
-func populateStatusChecks(client *http.Client, repo ghrepo.Interface, pr *api.PullRequest, requiredChecks bool) ([]check, checkCounts, error) {
+func populateStatusChecks(client *http.Client, repo ghrepo.Interface, pr *api.PullRequest, requiredChecks bool, includeEvent bool) ([]check, checkCounts, error) {
 	apiClient := api.NewClientFromHTTP(client)
 
 	type response struct {
@@ -224,7 +237,7 @@ func populateStatusChecks(client *http.Client, repo ghrepo.Interface, pr *api.Pu
 				%s
 			}
 		}
-	}`, api.RequiredStatusCheckRollupGraphQL("$id", "$endCursor"))
+	}`, api.RequiredStatusCheckRollupGraphQL("$id", "$endCursor", includeEvent))
 
 	variables := map[string]interface{}{
 		"id": pr.ID,
diff --git a/pkg/cmd/pr/checks/fixtures/withEvents.json b/pkg/cmd/pr/checks/fixtures/withEvents.json
new file mode 100644
index 000000000..b617e22d3
--- /dev/null
+++ b/pkg/cmd/pr/checks/fixtures/withEvents.json
@@ -0,0 +1,55 @@
+{
+  "data": {
+    "node": {
+      "statusCheckRollup": {
+        "nodes": [
+          {
+            "commit": {
+              "oid": "abc",
+              "statusCheckRollup": {
+                "contexts": {
+                  "nodes": [
+                    {
+                      "conclusion": "SUCCESS",
+                      "status": "COMPLETED",
+                      "name": "cool tests",
+                      "description": "cool description",
+                      "completedAt": "2020-08-27T19:00:12Z",
+                      "startedAt": "2020-08-27T18:58:46Z",
+                      "detailsUrl": "sweet link",
+                      "checkSuite": {
+                        "workflowRun": {
+                          "event": "pull_request",
+                          "workflow": {
+                            "name": "tests"
+                          }
+                        }
+                      }
+                    },
+                    {
+                      "conclusion": "SUCCESS",
+                      "status": "COMPLETED",
+                      "name": "cool tests",
+                      "description": "cool description",
+                      "completedAt": "2020-08-27T19:00:12Z",
+                      "startedAt": "2020-08-27T18:58:46Z",
+                      "detailsUrl": "sweet link",
+                      "checkSuite": {
+                        "workflowRun": {
+                          "event": "push",
+                          "workflow": {
+                            "name": "tests"
+                          }
+                        }
+                      }
+                    }
+                  ]
+                }
+              }
+            }
+          }
+        ]
+      }
+    }
+  }
+}
diff --git a/pkg/cmd/pr/checks/fixtures/withoutEvents.json b/pkg/cmd/pr/checks/fixtures/withoutEvents.json
new file mode 100644
index 000000000..f12f82aa8
--- /dev/null
+++ b/pkg/cmd/pr/checks/fixtures/withoutEvents.json
@@ -0,0 +1,53 @@
+{
+  "data": {
+    "node": {
+      "statusCheckRollup": {
+        "nodes": [
+          {
+            "commit": {
+              "oid": "abc",
+              "statusCheckRollup": {
+                "contexts": {
+                  "nodes": [
+                    {
+                      "conclusion": "SUCCESS",
+                      "status": "COMPLETED",
+                      "name": "cool tests",
+                      "description": "cool description",
+                      "completedAt": "2020-08-27T19:00:12Z",
+                      "startedAt": "2020-08-27T18:58:46Z",
+                      "detailsUrl": "sweet link",
+                      "checkSuite": {
+                        "workflowRun": {
+                          "workflow": {
+                            "name": "tests"
+                          }
+                        }
+                      }
+                    },
+                    {
+                      "conclusion": "SUCCESS",
+                      "status": "COMPLETED",
+                      "name": "cool tests",
+                      "description": "cool description",
+                      "completedAt": "2020-08-27T19:00:12Z",
+                      "startedAt": "2020-08-27T18:58:46Z",
+                      "detailsUrl": "sweet link",
+                      "checkSuite": {
+                        "workflowRun": {
+                          "workflow": {
+                            "name": "tests"
+                          }
+                        }
+                      }
+                    }
+                  ]
+                }
+              }
+            }
+          }
+        ]
+      }
+    }
+  }
+}
diff --git a/pkg/cmd/release/download/download.go b/pkg/cmd/release/download/download.go
index 2c7f4a18a..16e95db29 100644
--- a/pkg/cmd/release/download/download.go
+++ b/pkg/cmd/release/download/download.go
@@ -290,7 +290,7 @@ func downloadAsset(dest *destinationWriter, httpClient *http.Client, assetURL, f
 			return fmt.Errorf("unable to parse file name of archive: %w", err)
 		}
 		if serverFileName, ok := params["filename"]; ok {
-			fileName = serverFileName
+			fileName = filepath.Clean(serverFileName)
 		} else {
 			return errors.New("unable to determine file name of archive")
 		}
diff --git a/pkg/cmd/repo/view/http.go b/pkg/cmd/repo/view/http.go
index fe2584a90..da7196fef 100644
--- a/pkg/cmd/repo/view/http.go
+++ b/pkg/cmd/repo/view/http.go
@@ -1,13 +1,17 @@
 package view
 
 import (
+	"bytes"
 	"encoding/base64"
 	"errors"
 	"fmt"
+	"io"
 	"net/http"
 
 	"github.com/cli/cli/v2/api"
+	"github.com/cli/cli/v2/internal/asciisanitizer"
 	"github.com/cli/cli/v2/internal/ghrepo"
+	"golang.org/x/text/transform"
 )
 
 var NotFoundError = errors.New("not found")
@@ -40,9 +44,14 @@ func RepositoryReadme(client *http.Client, repo ghrepo.Interface, branch string)
 		return nil, fmt.Errorf("failed to decode readme: %w", err)
 	}
 
+	sanitized, err := io.ReadAll(transform.NewReader(bytes.NewReader(decoded), &asciisanitizer.Sanitizer{}))
+	if err != nil {
+		return nil, err
+	}
+
 	return &RepoReadme{
 		Filename: response.Name,
-		Content:  string(decoded),
+		Content:  string(sanitized),
 		BaseURL:  response.HTMLURL,
 	}, nil
 }
diff --git a/pkg/cmd/workflow/shared/shared.go b/pkg/cmd/workflow/shared/shared.go
index efd21529e..11b0bd03e 100644
--- a/pkg/cmd/workflow/shared/shared.go
+++ b/pkg/cmd/workflow/shared/shared.go
@@ -1,9 +1,11 @@
 package shared
 
 import (
+	"bytes"
 	"encoding/base64"
 	"errors"
 	"fmt"
+	"io"
 	"net/url"
 	"path"
 	"strconv"
@@ -11,9 +13,11 @@ import (
 
 	"github.com/AlecAivazis/survey/v2"
 	"github.com/cli/cli/v2/api"
+	"github.com/cli/cli/v2/internal/asciisanitizer"
 	"github.com/cli/cli/v2/internal/ghrepo"
 	"github.com/cli/cli/v2/pkg/iostreams"
 	"github.com/cli/cli/v2/pkg/prompt"
+	"golang.org/x/text/transform"
 )
 
 const (
@@ -243,5 +247,10 @@ func GetWorkflowContent(client *api.Client, repo ghrepo.Interface, workflow Work
 		return nil, fmt.Errorf("failed to decode workflow file: %w", err)
 	}
 
-	return decoded, nil
+	sanitized, err := io.ReadAll(transform.NewReader(bytes.NewReader(decoded), &asciisanitizer.Sanitizer{}))
+	if err != nil {
+		return nil, err
+	}
+
+	return sanitized, nil
 }


Test patch:
diff --git a/internal/asciisanitizer/sanitizer_test.go b/internal/asciisanitizer/sanitizer_test.go
new file mode 100644
index 000000000..80b251c5f
--- /dev/null
+++ b/internal/asciisanitizer/sanitizer_test.go
@@ -0,0 +1,87 @@
+package asciisanitizer
+
+import (
+	"bytes"
+	"testing"
+	"testing/iotest"
+
+	"github.com/stretchr/testify/require"
+	"golang.org/x/text/transform"
+)
+
+func TestSanitizerTransform(t *testing.T) {
+	tests := []struct {
+		name  string
+		input string
+		want  string
+	}{
+		{
+			name:  "No control characters",
+			input: "The quick brown fox jumped over the lazy dog",
+			want:  "The quick brown fox jumped over the lazy dog",
+		},
+		{
+			name:  "Encoded C0 control character",
+			input: `1\u0001`,
+			want:  "1^A",
+		},
+		{
+			name: "Encoded C0 control characters",
+			input: `1\u0001 2\u0002 3\u0003 4\u0004 5\u0005 6\u0006 7\u0007 8\u0008 9\t ` +
+				`A\r\n B\u000b C\u000c D\r\n E\u000e F\u000f ` +
+				`10\u0010 11\u0011 12\u0012 13\u0013 14\u0014 15\u0015 16\u0016 17\u0017 18\u0018 19\u0019 ` +
+				`1A\u001a 1B\u001b 1C\u001c 1D\u001d 1E\u001e 1F\u001f ` +
+				`\\u00\u001b ` +
+				`\u001B \\u001B \\\u001B \\\\u001B `,
+			want: `1^A 2^B 3^C 4^D 5^E 6^F 7^G 8^H 9\t ` +
+				`A\r\n B^K C^L D\r\n E^N F^O ` +
+				`10^P 11^Q 12^R 13^S 14^T 15^U 16^V 17^W 18^X 19^Y ` +
+				`1A^Z 1B^[ 1C^\\ 1D^] 1E^^ 1F^_ ` +
+				`\\u00^[ ` +
+				`^[ \\^[ \\^[ \\\\^[ `,
+		},
+		{
+			name:  "C0 control character",
+			input: "1\x01",
+			want:  "1^A",
+		},
+		{
+			name: "C0 control characters",
+			input: "0\x00 1\x01 2\x02 3\x03 4\x04 5\x05 6\x06 7\x07 8\x08 9\x09 " +
+				"A\x0A B\x0B C\x0C D\x0D E\x0E F\x0F " +
+				"10\x10 11\x11 12\x12 13\x13 14\x14 15\x15 16\x16 17\x17 18\x18 19\x19 " +
+				"1A\x1A 1B\x1B 1C\x1C 1D\x1D 1E\x1E 1F\x1F ",
+			want: "0^@ 1^A 2^B 3^C 4^D 5^E 6^F 7^G 8^H 9\t " +
+				"A\n B\v C^L D\r E^N F^O " +
+				"10^P 11^Q 12^R 13^S 14^T 15^U 16^V 17^W 18^X 19^Y " +
+				"1A^Z 1B^[ 1C^\\\\ 1D^] 1E^^ 1F^_ ",
+		},
+		{
+			name:  "C1 control character",
+			input: "80\xC2\x80",
+			want:  "80^@",
+		},
+		{
+			name: "C1 control characters",
+			input: "80\xC2\x80 81\xC2\x81 82\xC2\x82 83\xC2\x83 84\xC2\x84 85\xC2\x85 86\xC2\x86 87\xC2\x87 88\xC2\x88 89\xC2\x89 " +
+				"8A\xC2\x8A 8B\xC2\x8B 8C\xC2\x8C 8D\xC2\x8D 8E\xC2\x8E 8F\xC2\x8F " +
+				"90\xC2\x90 91\xC2\x91 92\xC2\x92 93\xC2\x93 94\xC2\x94 95\xC2\x95 96\xC2\x96 97\xC2\x97 98\xC2\x98 99\xC2\x99 " +
+				"9A\xC2\x9A 9B\xC2\x9B 9C\xC2\x9C 9D\xC2\x9D 9E\xC2\x9E 9F\xC2\x9F " +
+				"\xC2\xA1 ",
+			want: "80^@ 81^A 82^B 83^C 84^D 85^E 86^F 87^G 88^H 89^I " +
+				"8A^J 8B^K 8C^L 8D^M 8E^N 8F^O " +
+				"90^P 91^Q 92^R 93^S 94^T 95^U 96^V 97^W 98^X 99^Y " +
+				"9A^Z 9B^[ 9C^\\\\ 9D^] 9E^^ 9F^_ " +
+				"¡ ",
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			sanitizer := &Sanitizer{}
+			reader := bytes.NewReader([]byte(tt.input))
+			transformReader := transform.NewReader(reader, sanitizer)
+			err := iotest.TestReader(transformReader, []byte(tt.want))
+			require.NoError(t, err)
+		})
+	}
+}
diff --git a/internal/featuredetection/feature_detection_test.go b/internal/featuredetection/feature_detection_test.go
index dda6fe6a5..2a32e9a2d 100644
--- a/internal/featuredetection/feature_detection_test.go
+++ b/internal/featuredetection/feature_detection_test.go
@@ -82,7 +82,7 @@ func TestPullRequestFeatures(t *testing.T) {
 		wantErr       bool
 	}{
 		{
-			name:     "github.com with merge queue and status check counts by state",
+			name:     "github.com with all features",
 			hostname: "github.com",
 			queryResponse: map[string]string{
 				`query PullRequest_fields\b`: heredoc.Doc(`
@@ -104,10 +104,21 @@ func TestPullRequestFeatures(t *testing.T) {
 						}
 					}
 				}`),
+				`query PullRequest_fields2\b`: heredoc.Doc(`
+				{
+					"data": {
+						"WorkflowRun": {
+							"fields": [
+								{"name": "event"}
+							]
+						}
+					}
+				}`),
 			},
 			wantFeatures: PullRequestFeatures{
 				MergeQueue:                     true,
 				CheckRunAndStatusContextCounts: true,
+				CheckRunEvent:                  true,
 			},
 			wantErr: false,
 		},
@@ -131,15 +142,26 @@ func TestPullRequestFeatures(t *testing.T) {
 						}
 					}
 				}`),
+				`query PullRequest_fields2\b`: heredoc.Doc(`
+				{
+					"data": {
+						"WorkflowRun": {
+							"fields": [
+								{"name": "event"}
+							]
+						}
+					}
+				}`),
 			},
 			wantFeatures: PullRequestFeatures{
 				MergeQueue:                     false,
 				CheckRunAndStatusContextCounts: true,
+				CheckRunEvent:                  true,
 			},
 			wantErr: false,
 		},
 		{
-			name:     "GHE with merge queue and status check counts by state",
+			name:     "GHE with all features",
 			hostname: "git.my.org",
 			queryResponse: map[string]string{
 				`query PullRequest_fields\b`: heredoc.Doc(`
@@ -161,15 +183,26 @@ func TestPullRequestFeatures(t *testing.T) {
 						}
 					}
 				}`),
+				`query PullRequest_fields2\b`: heredoc.Doc(`
+				{
+					"data": {
+						"WorkflowRun": {
+							"fields": [
+								{"name": "event"}
+							]
+						}
+					}
+				}`),
 			},
 			wantFeatures: PullRequestFeatures{
 				MergeQueue:                     true,
 				CheckRunAndStatusContextCounts: true,
+				CheckRunEvent:                  true,
 			},
 			wantErr: false,
 		},
 		{
-			name:     "GHE without merge queue and status check counts by state",
+			name:     "GHE with no features",
 			hostname: "git.my.org",
 			queryResponse: map[string]string{
 				`query PullRequest_fields\b`: heredoc.Doc(`
@@ -183,10 +216,19 @@ func TestPullRequestFeatures(t *testing.T) {
 						}
 					}
 				}`),
+				`query PullRequest_fields2\b`: heredoc.Doc(`
+				{
+					"data": {
+						"WorkflowRun": {
+							"fields": []
+						}
+					}
+				}`),
 			},
 			wantFeatures: PullRequestFeatures{
 				MergeQueue:                     false,
 				CheckRunAndStatusContextCounts: false,
+				CheckRunEvent:                  false,
 			},
 			wantErr: false,
 		},
diff --git a/pkg/cmd/pr/checks/checks_test.go b/pkg/cmd/pr/checks/checks_test.go
index a043176aa..e73e15731 100644
--- a/pkg/cmd/pr/checks/checks_test.go
+++ b/pkg/cmd/pr/checks/checks_test.go
@@ -9,6 +9,7 @@ import (
 
 	"github.com/cli/cli/v2/api"
 	"github.com/cli/cli/v2/internal/browser"
+	fd "github.com/cli/cli/v2/internal/featuredetection"
 	"github.com/cli/cli/v2/internal/ghrepo"
 	"github.com/cli/cli/v2/internal/run"
 	"github.com/cli/cli/v2/pkg/cmd/pr/shared"
@@ -123,14 +124,15 @@ func TestNewCmdChecks(t *testing.T) {
 
 func Test_checksRun(t *testing.T) {
 	tests := []struct {
-		name      string
-		tty       bool
-		watch     bool
-		failFast  bool
-		required  bool
-		httpStubs func(*httpmock.Registry)
-		wantOut   string
-		wantErr   string
+		name            string
+		tty             bool
+		watch           bool
+		failFast        bool
+		required        bool
+		disableDetector bool
+		httpStubs       func(*httpmock.Registry)
+		wantOut         string
+		wantErr         string
 	}{
 		{
 			name: "no commits tty",
@@ -393,6 +395,54 @@ func Test_checksRun(t *testing.T) {
 			wantOut: "awesome tests\tpass\t1m26s\tsweet link\tawesome description\ncool tests\tpass\t1m26s\tsweet link\tcool description\nrad tests\tpass\t1m26s\tsweet link\trad description\n",
 			wantErr: "",
 		},
+		{
+			name: "events tty",
+			tty:  true,
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.GraphQL(`query PullRequestStatusChecks\b`),
+					httpmock.FileResponse("./fixtures/withEvents.json"),
+				)
+			},
+			wantOut: "All checks were successful\n0 failing, 2 successful, 0 skipped, and 0 pending checks\n\n✓  tests/cool tests (pull_request)  cool description  1m26s  sweet link\n✓  tests/cool tests (push)          cool description  1m26s  sweet link\n",
+			wantErr: "",
+		},
+		{
+			name:            "events not supported tty",
+			tty:             true,
+			disableDetector: true,
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.GraphQL(`query PullRequestStatusChecks\b`),
+					httpmock.FileResponse("./fixtures/withoutEvents.json"),
+				)
+			},
+			wantOut: "All checks were successful\n0 failing, 1 successful, 0 skipped, and 0 pending checks\n\n✓  tests/cool tests  cool description  1m26s  sweet link\n",
+			wantErr: "",
+		},
+		{
+			name: "events",
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.GraphQL(`query PullRequestStatusChecks\b`),
+					httpmock.FileResponse("./fixtures/withEvents.json"),
+				)
+			},
+			wantOut: "cool tests\tpass\t1m26s\tsweet link\tcool description\ncool tests\tpass\t1m26s\tsweet link\tcool description\n",
+			wantErr: "",
+		},
+		{
+			name:            "events not supported",
+			disableDetector: true,
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.GraphQL(`query PullRequestStatusChecks\b`),
+					httpmock.FileResponse("./fixtures/withoutEvents.json"),
+				)
+			},
+			wantOut: "cool tests\tpass\t1m26s\tsweet link\tcool description\n",
+			wantErr: "",
+		},
 	}
 
 	for _, tt := range tests {
@@ -407,7 +457,14 @@ func Test_checksRun(t *testing.T) {
 				tt.httpStubs(reg)
 			}
 
+			var detector fd.Detector
+			detector = &fd.EnabledDetectorMock{}
+			if tt.disableDetector {
+				detector = &fd.DisabledDetectorMock{}
+			}
+
 			response := &api.PullRequest{Number: 123, HeadRefName: "trunk"}
+
 			opts := &ChecksOptions{
 				HttpClient: func() (*http.Client, error) {
 					return &http.Client{Transport: reg}, nil
@@ -415,6 +472,7 @@ func Test_checksRun(t *testing.T) {
 				IO:          ios,
 				SelectorArg: "123",
 				Finder:      shared.NewMockFinder("123", response, ghrepo.New("OWNER", "REPO")),
+				Detector:    detector,
 				Watch:       tt.watch,
 				FailFast:    tt.failFast,
 				Required:    tt.required,