cli/internal/featuredetection/feature_detection.go
Babak K. Shandiz d56a902a07
docs(featuredetection): add godoc for min GHES version for advanced issue search
Signed-off-by: Babak K. Shandiz <babakks@github.com>
2025-09-02 12:09:38 +01:00

380 lines
12 KiB
Go

package featuredetection
import (
"net/http"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/internal/gh"
"github.com/hashicorp/go-version"
"golang.org/x/sync/errgroup"
ghauth "github.com/cli/go-gh/v2/pkg/auth"
)
type Detector interface {
IssueFeatures() (IssueFeatures, error)
PullRequestFeatures() (PullRequestFeatures, error)
RepositoryFeatures() (RepositoryFeatures, error)
ProjectsV1() gh.ProjectsV1Support
SearchFeatures() (SearchFeatures, error)
}
type IssueFeatures struct {
StateReason bool
ActorIsAssignable bool
}
var allIssueFeatures = IssueFeatures{
StateReason: true,
ActorIsAssignable: true,
}
type PullRequestFeatures struct {
MergeQueue bool
// CheckRunAndStatusContextCounts indicates whether the API supports
// the checkRunCount, checkRunCountsByState, statusContextCount and statusContextCountsByState
// fields on the StatusCheckRollupContextConnection
CheckRunAndStatusContextCounts bool
CheckRunEvent bool
}
var allPullRequestFeatures = PullRequestFeatures{
MergeQueue: true,
CheckRunAndStatusContextCounts: true,
CheckRunEvent: true,
}
type RepositoryFeatures struct {
PullRequestTemplateQuery bool
VisibilityField bool
AutoMerge bool
}
var allRepositoryFeatures = RepositoryFeatures{
PullRequestTemplateQuery: true,
VisibilityField: true,
AutoMerge: true,
}
type SearchFeatures struct {
// AdvancedIssueSearch indicates whether the host supports advanced issue
// search via API calls.
AdvancedIssueSearchAPI bool
// AdvancedIssueSearchOptIn indicates whether the host supports advanced
// issue search as an opt-in feature, which has to be explicitly enabled in
// API calls.
AdvancedIssueSearchAPIOptIn bool
// AdvancedIssueSearchWebInIssuesTab indicates whether the host supports
// advanced issue search syntax in the Issues tab of repositories.
AdvancedIssueSearchWebInIssuesTab bool
// TODO advancedSearchFuture
// When advanced issue search is supported in Pull Requests tab, or in
// global search we can introduce more fields to reflect the support status.
}
// advancedIssueSearchNotSupported mimics GHE <3.18 where advanced issue search
// is either not supported or is not meant to be used due to not being stable
// enough (i.e. in preview).
var advancedIssueSearchNotSupported = SearchFeatures{
AdvancedIssueSearchAPI: false,
}
// advancedIssueSearchSupportedAsOptIn mimics github.com and GHE >=3.18 before
// the full cleanup of temp types (i.e. ISSUE_ADVANCED search type is still
// present on the schema).
var advancedIssueSearchSupportedAsOptIn = SearchFeatures{
AdvancedIssueSearchAPI: true,
AdvancedIssueSearchAPIOptIn: true,
AdvancedIssueSearchWebInIssuesTab: true,
}
// advancedIssueSearchSupportedAsOnlyBackend mimics github.com and GHE >=3.18
// after the full cleanup of temp types (i.e. ISSUE_ADVANCED search type is
// removed from the schema).
var advancedIssueSearchSupportedAsOnlyBackend = SearchFeatures{
AdvancedIssueSearchAPI: true,
AdvancedIssueSearchAPIOptIn: false,
AdvancedIssueSearchWebInIssuesTab: true,
}
type detector struct {
host string
httpClient *http.Client
}
func NewDetector(httpClient *http.Client, host string) Detector {
return &detector{
httpClient: httpClient,
host: host,
}
}
func (d *detector) IssueFeatures() (IssueFeatures, error) {
if !ghauth.IsEnterprise(d.host) {
return allIssueFeatures, nil
}
features := IssueFeatures{
StateReason: false,
ActorIsAssignable: false, // replaceActorsForAssignable GraphQL mutation unavailable on GHES
}
var featureDetection struct {
Issue struct {
Fields []struct {
Name string
} `graphql:"fields(includeDeprecated: true)"`
} `graphql:"Issue: __type(name: \"Issue\")"`
}
gql := api.NewClientFromHTTP(d.httpClient)
err := gql.Query(d.host, "Issue_fields", &featureDetection, nil)
if err != nil {
return features, err
}
for _, field := range featureDetection.Issue.Fields {
if field.Name == "stateReason" {
features.StateReason = true
}
}
return features, nil
}
func (d *detector) PullRequestFeatures() (PullRequestFeatures, error) {
// TODO: reinstate the short-circuit once the APIs are fully available on github.com
// https://github.com/cli/cli/issues/5778
//
// if !ghinstance.IsEnterprise(d.host) {
// return allPullRequestFeatures, nil
// }
var pullRequestFeatureDetection struct {
PullRequest struct {
Fields []struct {
Name string
} `graphql:"fields(includeDeprecated: true)"`
} `graphql:"PullRequest: __type(name: \"PullRequest\")"`
StatusCheckRollupContextConnection struct {
Fields []struct {
Name string
} `graphql:"fields(includeDeprecated: true)"`
} `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)
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
}
features := PullRequestFeatures{}
for _, field := range pullRequestFeatureDetection.PullRequest.Fields {
if field.Name == "isInMergeQueue" {
features.MergeQueue = true
}
}
for _, field := range pullRequestFeatureDetection.StatusCheckRollupContextConnection.Fields {
// We only check for checkRunCount here but it, checkRunCountsByState, statusContextCount and statusContextCountsByState
// were all introduced in the same version of the API.
if field.Name == "checkRunCount" {
features.CheckRunAndStatusContextCounts = true
}
}
for _, field := range pullRequestFeatureDetection2.WorkflowRun.Fields {
if field.Name == "event" {
features.CheckRunEvent = true
}
}
return features, nil
}
func (d *detector) RepositoryFeatures() (RepositoryFeatures, error) {
if !ghauth.IsEnterprise(d.host) {
return allRepositoryFeatures, nil
}
features := RepositoryFeatures{}
var featureDetection struct {
Repository struct {
Fields []struct {
Name string
} `graphql:"fields(includeDeprecated: true)"`
} `graphql:"Repository: __type(name: \"Repository\")"`
}
gql := api.NewClientFromHTTP(d.httpClient)
err := gql.Query(d.host, "Repository_fields", &featureDetection, nil)
if err != nil {
return features, err
}
for _, field := range featureDetection.Repository.Fields {
if field.Name == "pullRequestTemplates" {
features.PullRequestTemplateQuery = true
}
if field.Name == "visibility" {
features.VisibilityField = true
}
if field.Name == "autoMergeAllowed" {
features.AutoMerge = true
}
}
return features, nil
}
const (
enterpriseProjectsV1Removed = "3.17.0"
)
func (d *detector) ProjectsV1() gh.ProjectsV1Support {
if !ghauth.IsEnterprise(d.host) {
return gh.ProjectsV1Unsupported
}
hostVersion, hostVersionErr := resolveEnterpriseVersion(d.httpClient, d.host)
v1ProjectCutoffVersion, v1ProjectCutoffVersionErr := version.NewVersion(enterpriseProjectsV1Removed)
if hostVersionErr == nil && v1ProjectCutoffVersionErr == nil && hostVersion.LessThan(v1ProjectCutoffVersion) {
return gh.ProjectsV1Supported
}
return gh.ProjectsV1Unsupported
}
const (
// enterpriseAdvancedIssueSearchSupport is the minimum version of GHES that
// supports advanced issue search and gh should use it.
//
// Note that advanced issue search is also available on GHES 3.17, but it's
// at the preview stage and is not as mature as it is on github.com or later
// GHES version.
enterpriseAdvancedIssueSearchSupport = "3.18.0"
)
func (d *detector) SearchFeatures() (SearchFeatures, error) {
// TODO advancedIssueSearchCleanup
// Once GHES 3.17 support ends, we don't need this and, probably, the entire search feature detection.
// Regarding the release of advanced issue search (AIS, for short), there
// are three time spans/periods:
//
// 1. Pre-deprecation (< 4 Sep): where both legacy search and AIS are available
// - GraphQL: `ISSUE` and `ISSUE_ADVANCED` search types in GraphQL behave differently
// - REST: `advance_search=true` query parameter can be used to switch to AIS
// 2. Deprecation (>= 4 Sep): only AIS available
// - GraphQL: `ISSUE` and `ISSUE_ADVANCED` search types in GraphQL behave the same (AIS)
// - REST: `advance_search` query parameter has no effect (AIS)
// 3. Cleanup (>= TBD): only AIS available
// - GraphQL: `ISSUE` search type in GraphQL is the only available option (AIS)
// - REST: `advance_search` query parameter has no effect (AIS)
//
// Since there's no schema-wise difference between pre-deprecation and
// deprecation periods (i.e. `ISSUE_ADVANCED` is available during both),
// we cannot figure out the exact time period. The consensus is to to use
// the advanced search syntax during both periods.
var feature SearchFeatures
if ghauth.IsEnterprise(d.host) {
enterpriseAISSupportVersion, err := version.NewVersion(enterpriseAdvancedIssueSearchSupport)
if err != nil {
return SearchFeatures{}, err
}
hostVersion, err := resolveEnterpriseVersion(d.httpClient, d.host)
if err != nil {
return SearchFeatures{}, err
}
if hostVersion.GreaterThanOrEqual(enterpriseAISSupportVersion) {
// As of August 2025, advanced issue search is going to be available
// on GHES 3.18+, including Issues tabs in repositories.
feature.AdvancedIssueSearchAPI = true
feature.AdvancedIssueSearchWebInIssuesTab = true
// TODO advancedSearchFuture
// When the advanced search syntax is supported in global search or
// Pull Requests tabs (in repositories), we can add and enable the
// corresponding fields.
}
} else {
// As of August 2025, advanced issue search is available on github.com,
// including Issues tabs in repositories.
feature.AdvancedIssueSearchAPI = true
feature.AdvancedIssueSearchWebInIssuesTab = true
// TODO advancedSearchFuture
// When the advanced search syntax is supported in global search or
// Pull Requests tabs (in repositories), we can add and enable the
// corresponding fields.
}
if !feature.AdvancedIssueSearchAPI {
return feature, nil
}
var searchTypeFeatureDetection struct {
SearchType struct {
EnumValues []struct {
Name string
} `graphql:"enumValues(includeDeprecated: true)"`
} `graphql:"SearchType: __type(name: \"SearchType\")"`
}
gql := api.NewClientFromHTTP(d.httpClient)
if err := gql.Query(d.host, "SearchType_enumValues", &searchTypeFeatureDetection, nil); err != nil {
return SearchFeatures{}, err
}
for _, enumValue := range searchTypeFeatureDetection.SearchType.EnumValues {
if enumValue.Name == "ISSUE_ADVANCED" {
// As long as ISSUE_ADVANCED is present on the schema, we should
// explicitly opt-in when making API calls.
feature.AdvancedIssueSearchAPIOptIn = true
break
}
}
return feature, nil
}
func resolveEnterpriseVersion(httpClient *http.Client, host string) (*version.Version, error) {
var metaResponse struct {
InstalledVersion string `json:"installed_version"`
}
apiClient := api.NewClientFromHTTP(httpClient)
err := apiClient.REST(host, "GET", "meta", nil, &metaResponse)
if err != nil {
return nil, err
}
return version.NewVersion(metaResponse.InstalledVersion)
}