Expand the ApiActorsSupported doc comment to explain the two API generations (legacy AssignableUser vs actor-based AssignableActor), what each returns, and the specific GraphQL schema additions to check for when evaluating GHES support. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
516 lines
16 KiB
Go
516 lines
16 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
|
|
ProjectFeatures() (ProjectFeatures, error)
|
|
SearchFeatures() (SearchFeatures, error)
|
|
ReleaseFeatures() (ReleaseFeatures, error)
|
|
ActionsFeatures() (ActionsFeatures, error)
|
|
}
|
|
|
|
type IssueFeatures struct {
|
|
// TODO ApiActorsSupported
|
|
// ApiActorsSupported indicates the host supports actor-based APIs. True for
|
|
// github.com and ghe.com, false for GHES.
|
|
//
|
|
// The GitHub API has two generations of assignee/reviewer types:
|
|
//
|
|
// Legacy (GHES): Uses AssignableUser (users only) and node-ID-based mutations.
|
|
// - assignableUsers query returns []AssignableUser
|
|
// - Mutations take node IDs (assigneeIds, userReviewerIds, teamReviewerIds)
|
|
//
|
|
// Actor-based (github.com): Uses AssignableActor (User + Bot union) and
|
|
// login-based mutations, enabling assignment of non-user actors like Copilot.
|
|
// - suggestedActors query returns []AssignableActor (User | Bot)
|
|
// - suggestedReviewerActors returns []ReviewerCandidate (User | Bot | Team)
|
|
// - Mutations take logins (replaceActorsForAssignable, requestReviewsByLogin)
|
|
//
|
|
// When GHES adds support for the actor-based types and mutations, this flag
|
|
// can be removed and all // TODO ApiActorsSupported sites collapsed to the
|
|
// actor-only path. To verify GHES support, check whether the GHES GraphQL
|
|
// schema includes:
|
|
// - The suggestedActors field on Repository (assignee search)
|
|
// - The suggestedReviewerActors field on PullRequest (reviewer search)
|
|
// - The replaceActorsForAssignable mutation
|
|
// - The requestReviewsByLogin mutation
|
|
ApiActorsSupported bool
|
|
}
|
|
|
|
var allIssueFeatures = IssueFeatures{
|
|
ApiActorsSupported: 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 ProjectFeatures struct {
|
|
// ProjectItemQuery indicates support for the `query` argument on
|
|
// ProjectV2.items (supported on github.com and GHES 3.20+).
|
|
ProjectItemQuery bool
|
|
}
|
|
|
|
var allProjectFeatures = ProjectFeatures{
|
|
ProjectItemQuery: 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
|
|
|
|
// 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,
|
|
}
|
|
|
|
// 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,
|
|
}
|
|
|
|
type ReleaseFeatures struct {
|
|
ImmutableReleases bool
|
|
}
|
|
|
|
type ActionsFeatures struct {
|
|
// DispatchRunDetails indicates whether the API supports the `return_run_details`
|
|
// field in workflow dispatches that, when set to true, will return the details
|
|
// of the created workflow run in the response (with status code 200).
|
|
//
|
|
// On older API versions (e.g. GHES 3.20 or earlier), this new field is not
|
|
// supported and setting it will cause an error.
|
|
DispatchRunDetails bool
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
return IssueFeatures{
|
|
ApiActorsSupported: false, // TODO ApiActorsSupported — actor-based mutations unavailable on GHES
|
|
}, 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
|
|
}
|
|
|
|
func (d *detector) ProjectFeatures() (ProjectFeatures, error) {
|
|
if !ghauth.IsEnterprise(d.host) {
|
|
return allProjectFeatures, nil
|
|
}
|
|
|
|
var features ProjectFeatures
|
|
|
|
var featureDetection struct {
|
|
ProjectV2 struct {
|
|
Fields []struct {
|
|
Name string
|
|
Args []struct {
|
|
Name string
|
|
}
|
|
} `graphql:"fields(includeDeprecated: true)"`
|
|
} `graphql:"ProjectV2: __type(name: \"ProjectV2\")"`
|
|
}
|
|
|
|
gql := api.NewClientFromHTTP(d.httpClient)
|
|
err := gql.Query(d.host, "ProjectV2_fields", &featureDetection, nil)
|
|
if err != nil {
|
|
return features, err
|
|
}
|
|
|
|
for _, field := range featureDetection.ProjectV2.Fields {
|
|
if field.Name == "items" {
|
|
for _, arg := range field.Args {
|
|
if arg.Name == "query" {
|
|
features.ProjectItemQuery = true
|
|
break
|
|
}
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
return features, nil
|
|
}
|
|
|
|
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: 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: 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: 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
|
|
|
|
// 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
|
|
|
|
// 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 (d *detector) ReleaseFeatures() (ReleaseFeatures, error) {
|
|
// TODO: immutableReleaseFullSupport
|
|
// Once all supported GHES versions fully support immutable releases, we can
|
|
// remove this function, of course, unless there will be other release-related
|
|
// features that are not available on all GH hosts.
|
|
|
|
var releaseFeatureDetection struct {
|
|
Release struct {
|
|
Fields []struct {
|
|
Name string
|
|
} `graphql:"fields"`
|
|
} `graphql:"Release: __type(name: \"Release\")"`
|
|
}
|
|
|
|
gql := api.NewClientFromHTTP(d.httpClient)
|
|
if err := gql.Query(d.host, "Release_fields", &releaseFeatureDetection, nil); err != nil {
|
|
return ReleaseFeatures{}, err
|
|
}
|
|
|
|
for _, field := range releaseFeatureDetection.Release.Fields {
|
|
if field.Name == "immutable" {
|
|
return ReleaseFeatures{
|
|
ImmutableReleases: true,
|
|
}, nil
|
|
}
|
|
}
|
|
|
|
return ReleaseFeatures{}, nil
|
|
}
|
|
|
|
const (
|
|
enterpriseWorkflowDispatchRunDetailsSupport = "3.21.0"
|
|
)
|
|
|
|
func (d *detector) ActionsFeatures() (ActionsFeatures, error) {
|
|
// TODO workflowDispatchRunDetailsCleanup
|
|
// Once GHES 3.20 support ends, we don't need feature detection for workflow dispatch (i.e. run details support).
|
|
//
|
|
// On github.com, workflow dispatch API now supports a new field named `return_run_details` that enabling it will
|
|
// result in a 200 OK response with the details of the created workflow run. If not set (or set to false), the API
|
|
// will keep the old behavior of returning a 204 No Content response.
|
|
//
|
|
// On GHES (current latest at 3.20), this new field is not available, and setting it will cause a 400 response.
|
|
//
|
|
// Once GHES 3.20 support ends, we can remove the feature detection and start using the new field in API calls.
|
|
//
|
|
// IMPORTANT: In the future REST API versions (i.e. breaking changes), the workflow dispatch endpoint is going to
|
|
// always return the details of the created workflow run in the response, and the `return_run_details` field is
|
|
// going to be ignored/removed. So, once we are migrating to the new API version we should double check the status
|
|
// of the API.
|
|
|
|
if !ghauth.IsEnterprise(d.host) {
|
|
return ActionsFeatures{
|
|
DispatchRunDetails: true,
|
|
}, nil
|
|
}
|
|
|
|
minSupportedVersion, err := version.NewVersion(enterpriseWorkflowDispatchRunDetailsSupport)
|
|
if err != nil {
|
|
return ActionsFeatures{}, err
|
|
}
|
|
|
|
hostVersion, err := resolveEnterpriseVersion(d.httpClient, d.host)
|
|
if err != nil {
|
|
return ActionsFeatures{}, err
|
|
}
|
|
|
|
if hostVersion.GreaterThanOrEqual(minSupportedVersion) {
|
|
return ActionsFeatures{
|
|
DispatchRunDetails: true,
|
|
}, nil
|
|
}
|
|
|
|
return ActionsFeatures{
|
|
DispatchRunDetails: false,
|
|
}, 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)
|
|
}
|