From a6e80356088e179b27c84a1024796ab1055d2bc8 Mon Sep 17 00:00:00 2001 From: paul <423357+toothbrush@users.noreply.github.com> Date: Thu, 25 Jun 2026 11:28:51 +0930 Subject: [PATCH 01/17] chore(coreapi): refresh OpenAPI spec and regenerate client Re-pull upstream spec and regenerate. Adds mirror `status` field (processing/ready/failed/suspended). Generated changes only. Co-Authored-By: Claude Opus 4.8 (1M context) Entire-Checkpoint: d160d74ef29b --- internal/coreapi/oas_json_gen.go | 98 ++++++++++++- internal/coreapi/oas_response_decoders_gen.go | 9 ++ internal/coreapi/oas_schemas_gen.go | 138 ++++++++++++++++-- internal/coreapi/oas_validators_gen.go | 62 ++++++++ internal/coreapi/spec/core.gen.json | 10 ++ internal/coreapi/spec/core.openapi.json | 2 +- 6 files changed, 305 insertions(+), 14 deletions(-) diff --git a/internal/coreapi/oas_json_gen.go b/internal/coreapi/oas_json_gen.go index 749b389a1e..575067b295 100644 --- a/internal/coreapi/oas_json_gen.go +++ b/internal/coreapi/oas_json_gen.go @@ -12548,6 +12548,12 @@ func (s *Mirror) encodeFields(e *jx.Encoder) { e.FieldStart("repo") e.Str(s.Repo) } + { + if s.Status.Set { + e.FieldStart("status") + s.Status.Encode(e) + } + } { if s.SuspendedAt.Set { e.FieldStart("suspendedAt") @@ -12563,7 +12569,7 @@ func (s *Mirror) encodeFields(e *jx.Encoder) { } } -var jsonFieldsNameOfMirror = [13]string{ +var jsonFieldsNameOfMirror = [14]string{ 0: "$schema", 1: "cell", 2: "clusterHost", @@ -12576,7 +12582,8 @@ var jsonFieldsNameOfMirror = [13]string{ 9: "owner", 10: "provider", 11: "repo", - 12: "suspendedAt", + 12: "status", + 13: "suspendedAt", } // Decode decodes Mirror from json. @@ -12721,6 +12728,16 @@ func (s *Mirror) Decode(d *jx.Decoder) error { }(); err != nil { return errors.Wrap(err, "decode field \"repo\"") } + case "status": + if err := func() error { + s.Status.Reset() + if err := s.Status.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"status\"") + } case "suspendedAt": if err := func() error { s.SuspendedAt.Reset() @@ -13064,6 +13081,50 @@ func (s *MirrorCollaboratorAdditional) UnmarshalJSON(data []byte) error { return s.Decode(d) } +// Encode encodes MirrorStatus as json. +func (s MirrorStatus) Encode(e *jx.Encoder) { + e.Str(string(s)) +} + +// Decode decodes MirrorStatus from json. +func (s *MirrorStatus) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode MirrorStatus to nil") + } + v, err := d.StrBytes() + if err != nil { + return err + } + // Try to use constant string. + switch MirrorStatus(v) { + case MirrorStatusProcessing: + *s = MirrorStatusProcessing + case MirrorStatusReady: + *s = MirrorStatusReady + case MirrorStatusFailed: + *s = MirrorStatusFailed + case MirrorStatusSuspended: + *s = MirrorStatusSuspended + default: + *s = MirrorStatus(v) + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s MirrorStatus) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *MirrorStatus) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + // Encode implements json.Marshaler. func (s *OIDCProvider) Encode(e *jx.Encoder) { e.ObjStart() @@ -13758,6 +13819,39 @@ func (s *OptMeRegionalUnavailable) UnmarshalJSON(data []byte) error { return s.Decode(d) } +// Encode encodes MirrorStatus as json. +func (o OptMirrorStatus) Encode(e *jx.Encoder) { + if !o.Set { + return + } + e.Str(string(o.Value)) +} + +// Decode decodes MirrorStatus from json. +func (o *OptMirrorStatus) Decode(d *jx.Decoder) error { + if o == nil { + return errors.New("invalid: unable to decode OptMirrorStatus to nil") + } + o.Set = true + if err := o.Value.Decode(d); err != nil { + return err + } + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s OptMirrorStatus) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *OptMirrorStatus) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + // Encode encodes Org as json. func (o OptOrg) Encode(e *jx.Encoder) { if !o.Set { diff --git a/internal/coreapi/oas_response_decoders_gen.go b/internal/coreapi/oas_response_decoders_gen.go index 08622af55e..6a5bd0f865 100644 --- a/internal/coreapi/oas_response_decoders_gen.go +++ b/internal/coreapi/oas_response_decoders_gen.go @@ -1135,6 +1135,15 @@ func decodeGetMirrorResponse(resp *http.Response) (res *Mirror, _ error) { } return res, err } + // Validate response. + if err := func() error { + if err := response.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + return res, errors.Wrap(err, "validate") + } return &response, nil default: return res, validate.InvalidContentType(ct) diff --git a/internal/coreapi/oas_schemas_gen.go b/internal/coreapi/oas_schemas_gen.go index 53de5a6a9a..4a3b32736b 100644 --- a/internal/coreapi/oas_schemas_gen.go +++ b/internal/coreapi/oas_schemas_gen.go @@ -4669,17 +4669,20 @@ type Mirror struct { // Physical cell the mirror's cluster runs in, e.g. aws-us-east-2. Cell OptString `json:"cell"` // Public host of the cluster serving this mirror. - ClusterHost string `json:"clusterHost"` - CreatedAt time.Time `json:"createdAt"` - InstallationId OptInt64 `json:"installationId"` - IsArchived OptBool `json:"isArchived"` - IsPrivate OptBool `json:"isPrivate"` - Jurisdiction OptString `json:"jurisdiction"` - MirrorId string `json:"mirrorId"` - Owner string `json:"owner"` - Provider string `json:"provider"` - Repo string `json:"repo"` - SuspendedAt OptDateTime `json:"suspendedAt"` + ClusterHost string `json:"clusterHost"` + CreatedAt time.Time `json:"createdAt"` + InstallationId OptInt64 `json:"installationId"` + IsArchived OptBool `json:"isArchived"` + IsPrivate OptBool `json:"isPrivate"` + Jurisdiction OptString `json:"jurisdiction"` + MirrorId string `json:"mirrorId"` + Owner string `json:"owner"` + Provider string `json:"provider"` + Repo string `json:"repo"` + // Clone lifecycle: processing (cloning), ready (clonable), failed (initial clone failed), or + // suspended. + Status OptMirrorStatus `json:"status"` + SuspendedAt OptDateTime `json:"suspendedAt"` AdditionalProps MirrorAdditional } @@ -4743,6 +4746,11 @@ func (s *Mirror) GetRepo() string { return s.Repo } +// GetStatus returns the value of Status. +func (s *Mirror) GetStatus() OptMirrorStatus { + return s.Status +} + // GetSuspendedAt returns the value of SuspendedAt. func (s *Mirror) GetSuspendedAt() OptDateTime { return s.SuspendedAt @@ -4813,6 +4821,11 @@ func (s *Mirror) SetRepo(val string) { s.Repo = val } +// SetStatus sets the value of Status. +func (s *Mirror) SetStatus(val OptMirrorStatus) { + s.Status = val +} + // SetSuspendedAt sets the value of SuspendedAt. func (s *Mirror) SetSuspendedAt(val OptDateTime) { s.SuspendedAt = val @@ -4895,6 +4908,63 @@ func (s *MirrorCollaboratorAdditional) init() MirrorCollaboratorAdditional { return m } +// Clone lifecycle: processing (cloning), ready (clonable), failed (initial clone failed), or +// suspended. +type MirrorStatus string + +const ( + MirrorStatusProcessing MirrorStatus = "processing" + MirrorStatusReady MirrorStatus = "ready" + MirrorStatusFailed MirrorStatus = "failed" + MirrorStatusSuspended MirrorStatus = "suspended" +) + +// AllValues returns all MirrorStatus values. +func (MirrorStatus) AllValues() []MirrorStatus { + return []MirrorStatus{ + MirrorStatusProcessing, + MirrorStatusReady, + MirrorStatusFailed, + MirrorStatusSuspended, + } +} + +// MarshalText implements encoding.TextMarshaler. +func (s MirrorStatus) MarshalText() ([]byte, error) { + switch s { + case MirrorStatusProcessing: + return []byte(s), nil + case MirrorStatusReady: + return []byte(s), nil + case MirrorStatusFailed: + return []byte(s), nil + case MirrorStatusSuspended: + return []byte(s), nil + default: + return nil, errors.Errorf("invalid value: %q", s) + } +} + +// UnmarshalText implements encoding.TextUnmarshaler. +func (s *MirrorStatus) UnmarshalText(data []byte) error { + switch MirrorStatus(data) { + case MirrorStatusProcessing: + *s = MirrorStatusProcessing + return nil + case MirrorStatusReady: + *s = MirrorStatusReady + return nil + case MirrorStatusFailed: + *s = MirrorStatusFailed + return nil + case MirrorStatusSuspended: + *s = MirrorStatusSuspended + return nil + default: + return errors.Errorf("invalid value: %q", data) + } +} + // Ref: #/components/schemas/OIDCProvider type OIDCProvider struct { Description OptString `json:"description"` @@ -5655,6 +5725,52 @@ func (o OptMeRegionalUnavailable) Or(d MeRegionalUnavailable) MeRegionalUnavaila return d } +// NewOptMirrorStatus returns new OptMirrorStatus with value set to v. +func NewOptMirrorStatus(v MirrorStatus) OptMirrorStatus { + return OptMirrorStatus{ + Value: v, + Set: true, + } +} + +// OptMirrorStatus is optional MirrorStatus. +type OptMirrorStatus struct { + Value MirrorStatus + Set bool +} + +// IsSet returns true if OptMirrorStatus was set. +func (o OptMirrorStatus) IsSet() bool { return o.Set } + +// Reset unsets value. +func (o *OptMirrorStatus) Reset() { + var v MirrorStatus + o.Value = v + o.Set = false +} + +// SetTo sets value to v. +func (o *OptMirrorStatus) SetTo(v MirrorStatus) { + o.Set = true + o.Value = v +} + +// Get returns value and boolean that denotes whether value was set. +func (o OptMirrorStatus) Get() (v MirrorStatus, ok bool) { + if !o.Set { + return v, false + } + return o.Value, true +} + +// Or returns value if set, or given parameter if does not. +func (o OptMirrorStatus) Or(d MirrorStatus) MirrorStatus { + if v, ok := o.Get(); ok { + return v + } + return d +} + // NewOptOrg returns new OptOrg with value set to v. func NewOptOrg(v Org) OptOrg { return OptOrg{ diff --git a/internal/coreapi/oas_validators_gen.go b/internal/coreapi/oas_validators_gen.go index 2c0689eedf..bdafd1ddc4 100644 --- a/internal/coreapi/oas_validators_gen.go +++ b/internal/coreapi/oas_validators_gen.go @@ -1362,6 +1362,23 @@ func (s *ListMirrorsOutputBody) Validate() error { if s.Mirrors == nil { return errors.New("nil is invalid value") } + var failures []validate.FieldError + for i, elem := range s.Mirrors { + if err := func() error { + if err := elem.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: fmt.Sprintf("[%d]", i), + Error: err, + }) + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } return nil }(); err != nil { failures = append(failures, validate.FieldError{ @@ -1886,6 +1903,51 @@ func (s MeRegionalUnavailableError) Validate() error { } } +func (s *Mirror) Validate() error { + if s == nil { + return validate.ErrNilPointer + } + + var failures []validate.FieldError + if err := func() error { + if value, ok := s.Status.Get(); ok { + if err := func() error { + if err := value.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + return err + } + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "status", + Error: err, + }) + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil +} + +func (s MirrorStatus) Validate() error { + switch s { + case "processing": + return nil + case "ready": + return nil + case "failed": + return nil + case "suspended": + return nil + default: + return errors.Errorf("invalid value: %v", s) + } +} + func (s *Project) Validate() error { if s == nil { return validate.ErrNilPointer diff --git a/internal/coreapi/spec/core.gen.json b/internal/coreapi/spec/core.gen.json index b6c97655f6..71b9b2cf91 100644 --- a/internal/coreapi/spec/core.gen.json +++ b/internal/coreapi/spec/core.gen.json @@ -1659,6 +1659,16 @@ "repo": { "type": "string" }, + "status": { + "description": "Clone lifecycle: processing (cloning), ready (clonable), failed (initial clone failed), or suspended.", + "enum": [ + "processing", + "ready", + "failed", + "suspended" + ], + "type": "string" + }, "suspendedAt": { "format": "date-time", "type": "string" diff --git a/internal/coreapi/spec/core.openapi.json b/internal/coreapi/spec/core.openapi.json index ceaa8252ef..76fc5584d7 100644 --- a/internal/coreapi/spec/core.openapi.json +++ b/internal/coreapi/spec/core.openapi.json @@ -1 +1 @@ -{"components":{"schemas":{"AddOrgMemberInputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/AddOrgMemberInputBody.json"],"format":"uri","readOnly":true,"type":"string"},"provider":{"minLength":1,"type":"string"},"providerUserId":{"minLength":1,"type":"string"},"role":{"default":"member","description":"Role at the org; defaults to member.","enum":["owner","admin","member"],"type":"string"}},"required":["provider","providerUserId"],"type":"object"},"AuditEvent":{"additionalProperties":true,"properties":{"actorId":{"type":"string"},"eventType":{"type":"string"},"id":{"type":"string"},"ipAddress":{"description":"Source IP recorded when the event was logged.","type":"string"},"metadata":{"additionalProperties":{},"type":"object"},"occurredAt":{"format":"date-time","type":"string"}},"required":["id","occurredAt","eventType","actorId"],"type":"object"},"AvailableMirror":{"additionalProperties":true,"properties":{"access":{"description":"Caller's effective GitHub access: read, write, or admin.","enum":["read","write","admin"],"type":"string"},"isArchived":{"type":"boolean"},"isPrivate":{"type":"boolean"},"owner":{"type":"string"},"repo":{"type":"string"},"status":{"description":"available (can onboard), mirrored (already mirrored), or owner-only (personal repo of another user).","enum":["available","mirrored","owner-only"],"type":"string"}},"required":["owner","repo","access","status"],"type":"object"},"BatchLookupInputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/BatchLookupInputBody.json"],"format":"uri","readOnly":true,"type":"string"},"refs":{"items":{"$ref":"#/components/schemas/LookupRef"},"maxItems":100,"minItems":1,"type":"array"}},"required":["refs"],"type":"object"},"BatchLookupOutputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/BatchLookupOutputBody.json"],"format":"uri","readOnly":true,"type":"string"},"refs":{"items":{"$ref":"#/components/schemas/LookupRefResult"},"type":"array"}},"required":["refs"],"type":"object"},"Binding":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/Binding.json"],"format":"uri","readOnly":true,"type":"string"},"accountId":{"type":"string"},"attributeFilter":{},"createdAt":{"format":"date-time","type":"string"},"id":{"type":"string"},"providerId":{"type":"string"}},"required":["id","accountId","providerId","attributeFilter","createdAt"],"type":"object"},"Cluster":{"additionalProperties":true,"properties":{"isDefault":{"type":"boolean"},"jurisdiction":{"type":"string"},"publicUrl":{"type":"string"},"slug":{"type":"string"}},"required":["slug","jurisdiction","publicUrl","isDefault"],"type":"object"},"CreateBindingInputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/CreateBindingInputBody.json"],"format":"uri","readOnly":true,"type":"string"},"attributeFilter":{"description":"Exact-match key/value map; empty filter matches any token."},"providerId":{"minLength":1,"type":"string"}},"required":["providerId"],"type":"object"},"CreateMirrorInputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/CreateMirrorInputBody.json"],"format":"uri","readOnly":true,"type":"string"},"clusterHost":{"description":"DNS host of the destination cluster.","minLength":1,"type":"string"},"owner":{"minLength":1,"type":"string"},"provider":{"enum":["github"],"type":"string"},"repo":{"minLength":1,"type":"string"}},"required":["provider","owner","repo","clusterHost"],"type":"object"},"CreateOrgInputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/CreateOrgInputBody.json"],"format":"uri","readOnly":true,"type":"string"},"name":{"description":"Display name.","maxLength":100,"minLength":1,"type":"string"},"region":{"description":"Jurisdiction slug; defaults to the server's home jurisdiction.","type":"string"}},"required":["name"],"type":"object"},"CreateProjectInputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/CreateProjectInputBody.json"],"format":"uri","readOnly":true,"type":"string"},"name":{"maxLength":100,"minLength":1,"type":"string"},"ownerId":{"minLength":1,"type":"string"},"ownerType":{"enum":["org","account"],"type":"string"},"region":{"type":"string"}},"required":["name","ownerType","ownerId"],"type":"object"},"CreateRepoInputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/CreateRepoInputBody.json"],"format":"uri","readOnly":true,"type":"string"},"clusterHost":{"description":"Public host of the cluster to pin the repo to (e.g. royalcanin.partial.to); empty lands on the jurisdiction default.","type":"string"},"name":{"minLength":1,"type":"string"},"objectFormat":{"description":"Hash format; defaults to sha1.","enum":["sha1","sha256"],"type":"string"},"projectId":{"pattern":"^[0-9A-HJKMNP-TV-Z]{26}$","type":"string"}},"required":["projectId","name"],"type":"object"},"CreateServiceAccountInputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/CreateServiceAccountInputBody.json"],"format":"uri","readOnly":true,"type":"string"},"name":{"minLength":1,"type":"string"},"orgId":{"pattern":"^[0-9A-HJKMNP-TV-Z]{26}$","type":"string"}},"required":["orgId","name"],"type":"object"},"CreatedMirror":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/CreatedMirror.json"],"format":"uri","readOnly":true,"type":"string"},"created":{"description":"true on fresh creation; false when an existing mirror was returned.","type":"boolean"},"empty":{"description":"true when the upstream has no refs to clone.","type":"boolean"},"mirrorId":{"type":"string"},"mirrorUrl":{"type":"string"},"publicUrl":{"type":"string"}},"required":["mirrorId","mirrorUrl","publicUrl","created","empty"],"type":"object"},"ErrorDetail":{"additionalProperties":true,"properties":{"location":{"description":"Where the error occurred, e.g. 'body.items[3].tags' or 'path.thing-id'","type":"string"},"message":{"description":"Error message text","type":"string"},"value":{"description":"The value at the given location"}},"type":"object"},"ErrorModel":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/ErrorModel.json"],"format":"uri","readOnly":true,"type":"string"},"detail":{"description":"A human-readable explanation specific to this occurrence of the problem.","examples":["Property foo is required but is missing."],"type":"string"},"errors":{"description":"Optional list of individual error details","items":{"$ref":"#/components/schemas/ErrorDetail"},"type":"array"},"instance":{"description":"A URI reference that identifies the specific occurrence of the problem.","examples":["https://example.com/error-log/abc123"],"format":"uri","type":"string"},"status":{"description":"HTTP status code","examples":[400],"format":"int64","type":"integer"},"title":{"description":"A short, human-readable summary of the problem type. This value should not change between occurrences of the error.","examples":["Bad Request"],"type":"string"},"type":{"default":"about:blank","description":"A URI reference to human-readable documentation for the error.","examples":["https://example.com/errors/example"],"format":"uri","type":"string"}},"type":"object"},"GetMeOutputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/GetMeOutputBody.json"],"format":"uri","readOnly":true,"type":"string"},"auth":{"$ref":"#/components/schemas/MeAuth"},"global":{"$ref":"#/components/schemas/MeGlobal"},"jurisdiction":{"type":"string"},"mode":{"enum":["standalone","global","regional"],"type":"string"},"regional":{"$ref":"#/components/schemas/MeRegional"},"regionalUnavailable":{"$ref":"#/components/schemas/MeRegionalUnavailable"}},"required":["global","auth"],"type":"object"},"GetPermissionsOutputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/GetPermissionsOutputBody.json"],"format":"uri","readOnly":true,"type":"string"},"explain":{"additionalProperties":{},"type":"object"},"permissions":{"items":{"type":"string"},"type":"array"},"resourceId":{"type":"string"},"resourceType":{"type":"string"}},"required":["resourceType","resourceId"],"type":"object"},"GetVersionOutputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/GetVersionOutputBody.json"],"format":"uri","readOnly":true,"type":"string"},"mode":{"description":"Server mode.","enum":["standalone","global","regional"],"type":"string"},"version":{"description":"Git commit SHA of the running entire-core binary, or \"dev\" for an untagged local build.","type":"string"}},"required":["version"],"type":"object"},"GrantMirrorCollaboratorInputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/GrantMirrorCollaboratorInputBody.json"],"format":"uri","readOnly":true,"type":"string"},"clusterHost":{"description":"Public host of the cluster serving the mirror.","minLength":1,"type":"string"},"handle":{"description":"Qualified grantee handle, e.g. github:alice.","minLength":1,"type":"string"},"owner":{"minLength":1,"type":"string"},"provider":{"enum":["github"],"type":"string"},"repo":{"minLength":1,"type":"string"},"role":{"description":"Grant level: reader (pull) or writer (pull+push).","enum":["reader","writer"],"type":"string"}},"required":["provider","owner","repo","clusterHost","handle","role"],"type":"object"},"GrantProjectAccessInputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/GrantProjectAccessInputBody.json"],"format":"uri","readOnly":true,"type":"string"},"granteeType":{"default":"account","enum":["account"],"type":"string"},"provider":{"minLength":1,"type":"string"},"providerUserId":{"minLength":1,"type":"string"},"role":{"enum":["reader","writer","admin"],"type":"string"}},"required":["provider","providerUserId","role"],"type":"object"},"GrantProjectAccessOutputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/GrantProjectAccessOutputBody.json"],"format":"uri","readOnly":true,"type":"string"},"status":{"type":"string"}},"required":["status"],"type":"object"},"GrantRepoAccessInputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/GrantRepoAccessInputBody.json"],"format":"uri","readOnly":true,"type":"string"},"granteeType":{"default":"account","enum":["account"],"type":"string"},"provider":{"minLength":1,"type":"string"},"providerUserId":{"minLength":1,"type":"string"},"role":{"enum":["reader","writer","admin"],"type":"string"}},"required":["provider","providerUserId","role"],"type":"object"},"GrantRepoAccessOutputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/GrantRepoAccessOutputBody.json"],"format":"uri","readOnly":true,"type":"string"},"status":{"type":"string"}},"required":["status"],"type":"object"},"GrantServiceAccountAccessInputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/GrantServiceAccountAccessInputBody.json"],"format":"uri","readOnly":true,"type":"string"},"resourceId":{"minLength":1,"type":"string"},"resourceType":{"enum":["repo","project"],"type":"string"},"role":{"enum":["reader","writer","admin"],"type":"string"}},"required":["resourceType","resourceId","role"],"type":"object"},"GrantServiceAccountAccessOutputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/GrantServiceAccountAccessOutputBody.json"],"format":"uri","readOnly":true,"type":"string"},"status":{"type":"string"}},"required":["status"],"type":"object"},"GrantedMirrorCollaborator":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/GrantedMirrorCollaborator.json"],"format":"uri","readOnly":true,"type":"string"},"accountId":{"description":"Entire account the grant was written for.","type":"string"},"role":{"type":"string"}},"required":["accountId","role"],"type":"object"},"ListAuditEventsOutputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/ListAuditEventsOutputBody.json"],"format":"uri","readOnly":true,"type":"string"},"events":{"items":{"$ref":"#/components/schemas/AuditEvent"},"type":"array"},"nextPageToken":{"description":"Pass back to fetch the next page; empty when no more entries.","type":"string"}},"required":["events"],"type":"object"},"ListAvailableMirrorsOutputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/ListAvailableMirrorsOutputBody.json"],"format":"uri","readOnly":true,"type":"string"},"available":{"items":{"$ref":"#/components/schemas/AvailableMirror"},"type":"array"}},"required":["available"],"type":"object"},"ListBindingsOutputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/ListBindingsOutputBody.json"],"format":"uri","readOnly":true,"type":"string"},"bindings":{"items":{"$ref":"#/components/schemas/Binding"},"type":"array"},"nextPageToken":{"description":"Pass back to fetch the next page; empty when no more entries.","type":"string"}},"required":["bindings"],"type":"object"},"ListClustersOutputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/ListClustersOutputBody.json"],"format":"uri","readOnly":true,"type":"string"},"clusters":{"items":{"$ref":"#/components/schemas/Cluster"},"type":"array"}},"required":["clusters"],"type":"object"},"ListMirrorCollaboratorsOutputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/ListMirrorCollaboratorsOutputBody.json"],"format":"uri","readOnly":true,"type":"string"},"collaborators":{"items":{"$ref":"#/components/schemas/MirrorCollaborator"},"type":"array"}},"required":["collaborators"],"type":"object"},"ListMirrorsOutputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/ListMirrorsOutputBody.json"],"format":"uri","readOnly":true,"type":"string"},"mirrors":{"items":{"$ref":"#/components/schemas/Mirror"},"type":"array"},"nextPageToken":{"description":"Pass back to fetch the next page; empty when no more entries.","type":"string"}},"required":["mirrors"],"type":"object"},"ListOIDCProvidersOutputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/ListOIDCProvidersOutputBody.json"],"format":"uri","readOnly":true,"type":"string"},"nextPageToken":{"description":"Pass back to fetch the next page; empty when no more entries.","type":"string"},"providers":{"items":{"$ref":"#/components/schemas/OIDCProvider"},"type":"array"}},"required":["providers"],"type":"object"},"ListOrgMembersOutputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/ListOrgMembersOutputBody.json"],"format":"uri","readOnly":true,"type":"string"},"members":{"items":{"$ref":"#/components/schemas/Membership"},"type":"array"},"nextPageToken":{"description":"Pass back to fetch the next page; empty when no more entries.","type":"string"}},"required":["members"],"type":"object"},"ListOrgProjectsOutputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/ListOrgProjectsOutputBody.json"],"format":"uri","readOnly":true,"type":"string"},"nextPageToken":{"description":"Pass back to fetch the next page; empty when no more entries.","type":"string"},"project":{"$ref":"#/components/schemas/Project"},"projects":{"items":{"$ref":"#/components/schemas/Project"},"type":"array"}},"type":"object"},"ListOrgsOutputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/ListOrgsOutputBody.json"],"format":"uri","readOnly":true,"type":"string"},"nextPageToken":{"description":"Pass back to fetch the next page; empty when no more entries.","type":"string"},"org":{"$ref":"#/components/schemas/Org"},"orgs":{"items":{"$ref":"#/components/schemas/Org"},"type":"array"}},"type":"object"},"ListProjectMembersOutputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/ListProjectMembersOutputBody.json"],"format":"uri","readOnly":true,"type":"string"},"members":{"items":{"$ref":"#/components/schemas/ProjectGrant"},"type":"array"},"nextPageToken":{"description":"Pass back to fetch the next page; empty when no more entries.","type":"string"}},"required":["members"],"type":"object"},"ListProjectReposOutputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/ListProjectReposOutputBody.json"],"format":"uri","readOnly":true,"type":"string"},"nextPageToken":{"description":"Pass back to fetch the next page; empty when no more entries.","type":"string"},"repo":{"$ref":"#/components/schemas/Repo"},"repos":{"items":{"$ref":"#/components/schemas/Repo"},"type":"array"}},"type":"object"},"ListProjectsOutputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/ListProjectsOutputBody.json"],"format":"uri","readOnly":true,"type":"string"},"nextPageToken":{"description":"Pass back to fetch the next page; empty when no more entries.","type":"string"},"project":{"$ref":"#/components/schemas/Project"},"projects":{"items":{"$ref":"#/components/schemas/Project"},"type":"array"}},"type":"object"},"ListRepoGrantsOutputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/ListRepoGrantsOutputBody.json"],"format":"uri","readOnly":true,"type":"string"},"grants":{"items":{"$ref":"#/components/schemas/RepoGrant"},"type":"array"},"nextPageToken":{"description":"Pass back to fetch the next page; empty when no more entries.","type":"string"}},"required":["grants"],"type":"object"},"ListServiceAccountGrantsOutputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/ListServiceAccountGrantsOutputBody.json"],"format":"uri","readOnly":true,"type":"string"},"grants":{"items":{"$ref":"#/components/schemas/ServiceAccountGrant"},"type":"array"},"nextPageToken":{"description":"Pass back to fetch the next page; empty when no more entries.","type":"string"}},"required":["grants"],"type":"object"},"ListServiceAccountsOutputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/ListServiceAccountsOutputBody.json"],"format":"uri","readOnly":true,"type":"string"},"nextPageToken":{"description":"Pass back to fetch the next page; empty when no more entries.","type":"string"},"serviceAccounts":{"items":{"$ref":"#/components/schemas/ServiceAccountWithGrants"},"type":"array"}},"required":["serviceAccounts"],"type":"object"},"LookupRef":{"additionalProperties":true,"properties":{"id":{"minLength":1,"type":"string"},"type":{"description":"Resource type slug; \"org\", \"project\", \"repo\" are enriched, unknown types pass through.","minLength":1,"type":"string"}},"required":["type","id"],"type":"object"},"LookupRefResult":{"additionalProperties":true,"properties":{"id":{"type":"string"},"name":{"type":"string"},"ownerId":{"type":"string"},"ownerType":{"enum":["org","account"],"type":"string"},"projectId":{"type":"string"},"type":{"type":"string"},"url":{"type":"string"}},"required":["type","id"],"type":"object"},"LookupResourcesOutputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/LookupResourcesOutputBody.json"],"format":"uri","readOnly":true,"type":"string"},"nextPageToken":{"description":"Pass back to fetch the next page; empty when no more entries.","type":"string"},"permission":{"type":"string"},"resourceIds":{"items":{"type":"string"},"type":"array"},"resourceType":{"type":"string"},"resources":{"items":{"$ref":"#/components/schemas/ResourceAccess"},"type":"array"}},"required":["resourceType"],"type":"object"},"MeAuth":{"additionalProperties":true,"properties":{"provider":{"type":"string"},"providerUserId":{"type":"string"}},"required":["provider","providerUserId"],"type":"object"},"MeGlobal":{"additionalProperties":true,"properties":{"accountId":{"type":"string"},"avatarUrl":{"type":"string"},"handle":{"type":"string"},"handles":{"items":{"$ref":"#/components/schemas/MeIdentityHandle"},"type":"array"},"homeJurisdiction":{"type":"string"}},"required":["accountId","handles"],"type":"object"},"MeIdentityHandle":{"additionalProperties":true,"properties":{"email":{"description":"The provider's publicly-visible profile email (from the provider at login; may be empty). NOT the account's contact email.","type":"string"},"handle":{"type":"string"},"provider":{"type":"string"},"providerUserId":{"type":"string"}},"required":["provider","handle","providerUserId"],"type":"object"},"MeRegional":{"additionalProperties":true,"properties":{"bio":{"type":"string"},"company":{"type":"string"},"displayName":{"type":"string"},"email":{"type":"string"},"location":{"type":"string"}},"type":"object"},"MeRegionalUnavailable":{"additionalProperties":true,"properties":{"error":{"description":"Always 'foreign_jurisdiction'. Discriminator for client-side state machines.","enum":["foreign_jurisdiction"],"type":"string"},"homeCoreUrl":{"description":"Deep link into the home console for this account.","type":"string"},"jurisdiction":{"description":"The account's home jurisdiction (e.g. 'us', 'eu').","type":"string"},"message":{"description":"Human-readable copy ready to surface in a UI.","type":"string"}},"required":["error","jurisdiction","homeCoreUrl","message"],"type":"object"},"Membership":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/Membership.json"],"format":"uri","readOnly":true,"type":"string"},"accountId":{"type":"string"},"createdAt":{"format":"date-time","type":"string"},"id":{"type":"string"},"orgId":{"type":"string"},"role":{"type":"string"},"status":{"type":"string"},"workosOrgMembershipId":{"type":"string"}},"required":["id","accountId","orgId","role","status","createdAt"],"type":"object"},"Mirror":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/Mirror.json"],"format":"uri","readOnly":true,"type":"string"},"cell":{"description":"Physical cell the mirror's cluster runs in, e.g. aws-us-east-2.","type":"string"},"clusterHost":{"description":"Public host of the cluster serving this mirror.","type":"string"},"createdAt":{"format":"date-time","type":"string"},"installationId":{"format":"int64","type":"integer"},"isArchived":{"type":"boolean"},"isPrivate":{"type":"boolean"},"jurisdiction":{"type":"string"},"mirrorId":{"type":"string"},"owner":{"type":"string"},"provider":{"type":"string"},"repo":{"type":"string"},"suspendedAt":{"format":"date-time","type":"string"}},"required":["mirrorId","provider","owner","repo","clusterHost","createdAt"],"type":"object"},"MirrorCollaborator":{"additionalProperties":true,"properties":{"accountId":{"type":"string"},"handle":{"description":"Primary handle (provider:label), empty if none resolves.","type":"string"},"role":{"description":"reader (pull) or writer (pull+push).","type":"string"}},"required":["accountId","role"],"type":"object"},"OIDCProvider":{"additionalProperties":true,"properties":{"description":{"type":"string"},"displayName":{"type":"string"},"id":{"type":"string"},"issuer":{"type":"string"}},"required":["id","issuer"],"type":"object"},"Org":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/Org.json"],"format":"uri","readOnly":true,"type":"string"},"createdAt":{"format":"date-time","type":"string"},"id":{"type":"string"},"name":{"type":"string"},"region":{"type":"string"},"workosOrganizationId":{"type":"string"}},"required":["id","name","region","createdAt"],"type":"object"},"Project":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/Project.json"],"format":"uri","readOnly":true,"type":"string"},"createdAt":{"format":"date-time","type":"string"},"id":{"type":"string"},"name":{"type":"string"},"ownerId":{"type":"string"},"ownerType":{"enum":["org","account"],"type":"string"},"region":{"type":"string"}},"required":["id","name","ownerType","ownerId","region","createdAt"],"type":"object"},"ProjectGrant":{"additionalProperties":true,"properties":{"granteeId":{"type":"string"},"granteeType":{"type":"string"},"role":{"type":"string"}},"required":["granteeType","granteeId","role"],"type":"object"},"Repo":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/Repo.json"],"format":"uri","readOnly":true,"type":"string"},"clusterHost":{"type":"string"},"foreign":{"type":"boolean"},"id":{"type":"string"},"mirrorSuspended":{"type":"boolean"},"mirrorSuspendedAt":{"type":"string"},"name":{"type":"string"},"objectFormat":{"enum":["sha1","sha256"],"type":"string"},"owningProjectId":{"type":"string"},"path":{"type":"string"},"provisionAttempts":{"format":"int64","type":"integer"},"provisionReason":{"type":"string"},"state":{"enum":["provisioning","active","failed"],"type":"string"}},"required":["id","owningProjectId","name"],"type":"object"},"RepoGrant":{"additionalProperties":true,"properties":{"granteeId":{"type":"string"},"granteeType":{"type":"string"},"role":{"type":"string"}},"required":["granteeType","granteeId","role"],"type":"object"},"ResolvedIdentity":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/ResolvedIdentity.json"],"format":"uri","readOnly":true,"type":"string"},"accountId":{"type":"string"},"handle":{"type":"string"},"provider":{"type":"string"},"providerUserId":{"type":"string"}},"required":["accountId","provider","handle","providerUserId"],"type":"object"},"ResourceAccess":{"additionalProperties":true,"properties":{"permissions":{"items":{"type":"string"},"type":"array"},"resourceId":{"type":"string"}},"required":["resourceId","permissions"],"type":"object"},"ServiceAccount":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/ServiceAccount.json"],"format":"uri","readOnly":true,"type":"string"},"accountId":{"type":"string"},"createdAt":{"format":"date-time","type":"string"},"name":{"type":"string"},"orgId":{"type":"string"},"status":{"type":"string"},"systemManaged":{"type":"boolean"}},"required":["accountId","name","orgId","status","systemManaged","createdAt"],"type":"object"},"ServiceAccountGrant":{"additionalProperties":true,"properties":{"resourceId":{"type":"string"},"resourceName":{"type":"string"},"resourceType":{"type":"string"},"role":{"type":"string"}},"required":["resourceType","resourceId","role"],"type":"object"},"ServiceAccountWithGrants":{"additionalProperties":true,"properties":{"accountId":{"type":"string"},"createdAt":{"format":"date-time","type":"string"},"grants":{"items":{"type":"string"},"type":"array"},"name":{"type":"string"},"orgId":{"type":"string"},"status":{"type":"string"},"systemManaged":{"type":"boolean"}},"required":["grants","accountId","name","orgId","status","systemManaged","createdAt"],"type":"object"},"UpdateMeInputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/UpdateMeInputBody.json"],"format":"uri","readOnly":true,"type":"string"},"email":{"description":"Contact email.","format":"email","maxLength":254,"minLength":5,"type":"string"}},"required":["email"],"type":"object"},"UpdateMeOutputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/UpdateMeOutputBody.json"],"format":"uri","readOnly":true,"type":"string"},"email":{"type":"string"}},"required":["email"],"type":"object"}},"securitySchemes":{"bearerAuth":{"bearerFormat":"JWT","description":"Bearer token minted by entire-core's device-code flow or STS exchange.","scheme":"bearer","type":"http"},"sessionAuth":{"description":"Console session cookie issued by the browser login flow.","in":"cookie","name":"entire_session","type":"apiKey"}}},"info":{"description":"Entire control plane: identity, orgs, projects, repos, mirrors, service accounts.","title":"Entire Core API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/access/{resourceType}":{"get":{"operationId":"lookupResources","parameters":[{"description":"Maximum entries to return; server may cap further.","explode":false,"in":"query","name":"pageSize","schema":{"description":"Maximum entries to return; server may cap further.","format":"int32","maximum":500,"minimum":0,"type":"integer"}},{"description":"Opaque cursor from a previous response's nextPageToken.","explode":false,"in":"query","name":"pageToken","schema":{"description":"Opaque cursor from a previous response's nextPageToken.","type":"string"}},{"description":"SpiceDB resource type (e.g. \"repo\", \"project\", \"org\").","in":"path","name":"resourceType","required":true,"schema":{"description":"SpiceDB resource type (e.g. \"repo\", \"project\", \"org\").","minLength":1,"type":"string"}},{"description":"Optional: only list resources where the caller has this permission. pageSize/pageToken apply only when set.","explode":false,"in":"query","name":"permission","schema":{"description":"Optional: only list resources where the caller has this permission. pageSize/pageToken apply only when set.","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LookupResourcesOutputBody"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"List resources of a type the caller can access","tags":["identity"]}},"/access/{resourceType}/{resourceId}":{"get":{"operationId":"getPermissions","parameters":[{"in":"path","name":"resourceType","required":true,"schema":{"minLength":1,"type":"string"}},{"in":"path","name":"resourceId","required":true,"schema":{"minLength":1,"type":"string"}},{"description":"If set, return the SpiceDB trace for this permission instead of the permission list.","explode":false,"in":"query","name":"explain","schema":{"description":"If set, return the SpiceDB trace for this permission instead of the permission list.","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetPermissionsOutputBody"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"List the caller's permissions on a single resource","tags":["identity"]}},"/audit":{"get":{"operationId":"listAuditEvents","parameters":[{"description":"Maximum entries to return; server may cap further.","explode":false,"in":"query","name":"pageSize","schema":{"description":"Maximum entries to return; server may cap further.","format":"int32","maximum":500,"minimum":0,"type":"integer"}},{"description":"Opaque cursor from a previous response's nextPageToken.","explode":false,"in":"query","name":"pageToken","schema":{"description":"Opaque cursor from a previous response's nextPageToken.","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListAuditEventsOutputBody"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"List the calling account's recent audit events","tags":["identity"]}},"/clusters":{"get":{"operationId":"listClusters","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListClustersOutputBody"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"List data-plane clusters attached to this control plane","tags":["clusters"]}},"/identity/handles/{provider}/{handle}":{"get":{"operationId":"resolveHandle","parameters":[{"description":"IdP slug (e.g. \"github\").","in":"path","name":"provider","required":true,"schema":{"description":"IdP slug (e.g. \"github\").","minLength":1,"type":"string"}},{"description":"User-visible handle at the provider.","in":"path","name":"handle","required":true,"schema":{"description":"User-visible handle at the provider.","minLength":1,"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ResolvedIdentity"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Resolve account by external provider handle","tags":["identity"]}},"/lookup":{"post":{"operationId":"batchLookup","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BatchLookupInputBody"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BatchLookupOutputBody"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Batch-resolve (type, id) refs to enriched records","tags":["identity"]}},"/me":{"get":{"operationId":"getMe","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetMeOutputBody"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Get the calling account's identity and profile","tags":["identity"]},"patch":{"operationId":"updateMe","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateMeInputBody"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateMeOutputBody"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Update the calling account's contact email","tags":["identity"]}},"/mirrors":{"delete":{"operationId":"deleteMirror","parameters":[{"explode":false,"in":"query","name":"provider","required":true,"schema":{"enum":["github"],"type":"string"}},{"explode":false,"in":"query","name":"owner","required":true,"schema":{"minLength":1,"type":"string"}},{"explode":false,"in":"query","name":"repo","required":true,"schema":{"minLength":1,"type":"string"}},{"description":"Public host of the cluster serving the mirror.","explode":false,"in":"query","name":"clusterHost","required":true,"schema":{"description":"Public host of the cluster serving the mirror.","minLength":1,"type":"string"}}],"responses":{"204":{"description":"No Content"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"421":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Misdirected Request"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"502":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Gateway"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Delete a mirror by upstream coords + cluster host","tags":["mirrors"]},"get":{"operationId":"listMirrors","parameters":[{"description":"Maximum entries to return; server may cap further.","explode":false,"in":"query","name":"pageSize","schema":{"description":"Maximum entries to return; server may cap further.","format":"int32","maximum":500,"minimum":0,"type":"integer"}},{"description":"Opaque cursor from a previous response's nextPageToken.","explode":false,"in":"query","name":"pageToken","schema":{"description":"Opaque cursor from a previous response's nextPageToken.","type":"string"}},{"description":"Optional: restrict to mirrors on this cluster (public host, e.g. royalcanin.partial.to).","explode":false,"in":"query","name":"cluster","schema":{"description":"Optional: restrict to mirrors on this cluster (public host, e.g. royalcanin.partial.to).","type":"string"}},{"description":"Optional: restrict to mirrors of this upstream provider (e.g. \"github\").","explode":false,"in":"query","name":"provider","schema":{"description":"Optional: restrict to mirrors of this upstream provider (e.g. \"github\").","type":"string"}},{"description":"Optional: restrict to mirrors with this upstream owner login.","explode":false,"in":"query","name":"owner","schema":{"description":"Optional: restrict to mirrors with this upstream owner login.","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListMirrorsOutputBody"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"List mirrors visible to the caller","tags":["mirrors"]},"post":{"operationId":"createMirror","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateMirrorInputBody"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreatedMirror"}}},"description":"Created"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Conflict"},"412":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Precondition Failed"},"421":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Misdirected Request"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"502":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Gateway"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Create GitHub mirror","tags":["mirrors"]}},"/mirrors/available":{"get":{"operationId":"listAvailableMirrors","parameters":[{"description":"Optional: restrict to repos with this owner login.","explode":false,"in":"query","name":"owner","schema":{"description":"Optional: restrict to repos with this owner login.","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListAvailableMirrorsOutputBody"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"502":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Gateway"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"List GitHub repos the caller could onboard as mirrors","tags":["mirrors"]}},"/mirrors/collaborators":{"delete":{"operationId":"revokeMirrorCollaborator","parameters":[{"explode":false,"in":"query","name":"provider","required":true,"schema":{"enum":["github"],"type":"string"}},{"explode":false,"in":"query","name":"owner","required":true,"schema":{"minLength":1,"type":"string"}},{"explode":false,"in":"query","name":"repo","required":true,"schema":{"minLength":1,"type":"string"}},{"description":"Public host of the cluster serving the mirror.","explode":false,"in":"query","name":"clusterHost","required":true,"schema":{"description":"Public host of the cluster serving the mirror.","minLength":1,"type":"string"}},{"description":"Qualified grantee handle, e.g. github:alice.","explode":false,"in":"query","name":"handle","required":true,"schema":{"description":"Qualified grantee handle, e.g. github:alice.","minLength":1,"type":"string"}}],"responses":{"204":{"description":"No Content"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"421":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Misdirected Request"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"502":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Gateway"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Revoke a user's access to a mirror (live GitHub-admin gated)","tags":["mirrors"]},"get":{"operationId":"listMirrorCollaborators","parameters":[{"explode":false,"in":"query","name":"provider","required":true,"schema":{"enum":["github"],"type":"string"}},{"explode":false,"in":"query","name":"owner","required":true,"schema":{"minLength":1,"type":"string"}},{"explode":false,"in":"query","name":"repo","required":true,"schema":{"minLength":1,"type":"string"}},{"description":"Public host of the cluster serving the mirror.","explode":false,"in":"query","name":"clusterHost","required":true,"schema":{"description":"Public host of the cluster serving the mirror.","minLength":1,"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListMirrorCollaboratorsOutputBody"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"421":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Misdirected Request"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"502":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Gateway"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"List the principals with access to a mirror (live GitHub-admin gated)","tags":["mirrors"]},"post":{"operationId":"grantMirrorCollaborator","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GrantMirrorCollaboratorInputBody"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GrantedMirrorCollaborator"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Conflict"},"421":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Misdirected Request"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"502":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Gateway"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Grant a user reader/writer access to a mirror (live GitHub-admin gated)","tags":["mirrors"]}},"/mirrors/{mirrorId}":{"get":{"operationId":"getMirror","parameters":[{"in":"path","name":"mirrorId","required":true,"schema":{"pattern":"^[0-9A-HJKMNP-TV-Z]{26}$","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Mirror"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Get mirror by id","tags":["mirrors"]}},"/oidc-providers":{"get":{"operationId":"listOIDCProviders","parameters":[{"description":"Maximum entries to return; server may cap further.","explode":false,"in":"query","name":"pageSize","schema":{"description":"Maximum entries to return; server may cap further.","format":"int32","maximum":500,"minimum":0,"type":"integer"}},{"description":"Opaque cursor from a previous response's nextPageToken.","explode":false,"in":"query","name":"pageToken","schema":{"description":"Opaque cursor from a previous response's nextPageToken.","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListOIDCProvidersOutputBody"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"List federated OIDC identity providers","tags":["identity"]}},"/orgs":{"get":{"operationId":"listOrgs","parameters":[{"description":"Maximum entries to return; server may cap further.","explode":false,"in":"query","name":"pageSize","schema":{"description":"Maximum entries to return; server may cap further.","format":"int32","maximum":500,"minimum":0,"type":"integer"}},{"description":"Opaque cursor from a previous response's nextPageToken.","explode":false,"in":"query","name":"pageToken","schema":{"description":"Opaque cursor from a previous response's nextPageToken.","type":"string"}},{"description":"Optional: exact-match org name.","explode":false,"in":"query","name":"name","schema":{"description":"Optional: exact-match org name.","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListOrgsOutputBody"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"List organizations the caller can see (or one by name)","tags":["orgs"]},"post":{"operationId":"createOrg","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateOrgInputBody"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Org"}}},"description":"Created"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Create organization","tags":["orgs"]}},"/orgs/{orgId}":{"delete":{"operationId":"deleteOrg","parameters":[{"in":"path","name":"orgId","required":true,"schema":{"pattern":"^[0-9A-HJKMNP-TV-Z]{26}$","type":"string"}}],"responses":{"204":{"description":"No Content"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Conflict"},"421":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Misdirected Request"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Delete an organization","tags":["orgs"]},"get":{"operationId":"getOrg","parameters":[{"in":"path","name":"orgId","required":true,"schema":{"pattern":"^[0-9A-HJKMNP-TV-Z]{26}$","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Org"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Get an organization","tags":["orgs"]}},"/orgs/{orgId}/members":{"get":{"operationId":"listOrgMembers","parameters":[{"description":"Maximum entries to return; server may cap further.","explode":false,"in":"query","name":"pageSize","schema":{"description":"Maximum entries to return; server may cap further.","format":"int32","maximum":500,"minimum":0,"type":"integer"}},{"description":"Opaque cursor from a previous response's nextPageToken.","explode":false,"in":"query","name":"pageToken","schema":{"description":"Opaque cursor from a previous response's nextPageToken.","type":"string"}},{"in":"path","name":"orgId","required":true,"schema":{"pattern":"^[0-9A-HJKMNP-TV-Z]{26}$","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListOrgMembersOutputBody"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"List members of an organization","tags":["orgs"]},"post":{"operationId":"addOrgMember","parameters":[{"in":"path","name":"orgId","required":true,"schema":{"pattern":"^[0-9A-HJKMNP-TV-Z]{26}$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddOrgMemberInputBody"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Membership"}}},"description":"Created"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Add a member to an organization","tags":["orgs"]}},"/orgs/{orgId}/members/{provider}/{providerUserId}":{"delete":{"operationId":"removeOrgMember","parameters":[{"in":"path","name":"orgId","required":true,"schema":{"pattern":"^[0-9A-HJKMNP-TV-Z]{26}$","type":"string"}},{"in":"path","name":"provider","required":true,"schema":{"minLength":1,"type":"string"}},{"in":"path","name":"providerUserId","required":true,"schema":{"minLength":1,"type":"string"}}],"responses":{"204":{"description":"No Content"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Remove a member from an organization","tags":["orgs"]}},"/orgs/{orgId}/projects":{"get":{"operationId":"listOrgProjects","parameters":[{"description":"Maximum entries to return; server may cap further.","explode":false,"in":"query","name":"pageSize","schema":{"description":"Maximum entries to return; server may cap further.","format":"int32","maximum":500,"minimum":0,"type":"integer"}},{"description":"Opaque cursor from a previous response's nextPageToken.","explode":false,"in":"query","name":"pageToken","schema":{"description":"Opaque cursor from a previous response's nextPageToken.","type":"string"}},{"in":"path","name":"orgId","required":true,"schema":{"pattern":"^[0-9A-HJKMNP-TV-Z]{26}$","type":"string"}},{"description":"Optional: exact-match project name (case-insensitive).","explode":false,"in":"query","name":"name","schema":{"description":"Optional: exact-match project name (case-insensitive).","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListOrgProjectsOutputBody"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"List projects owned by an organization","tags":["projects"]}},"/projects":{"get":{"operationId":"listProjects","parameters":[{"description":"Maximum entries to return; server may cap further.","explode":false,"in":"query","name":"pageSize","schema":{"description":"Maximum entries to return; server may cap further.","format":"int32","maximum":500,"minimum":0,"type":"integer"}},{"description":"Opaque cursor from a previous response's nextPageToken.","explode":false,"in":"query","name":"pageToken","schema":{"description":"Opaque cursor from a previous response's nextPageToken.","type":"string"}},{"description":"Optional: exact-match project name.","explode":false,"in":"query","name":"name","schema":{"description":"Optional: exact-match project name.","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListProjectsOutputBody"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"List projects accessible to the caller (or one by name)","tags":["projects"]},"post":{"operationId":"createProject","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateProjectInputBody"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Project"}}},"description":"Created"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Create project","tags":["projects"]}},"/projects/{projectId}":{"delete":{"operationId":"deleteProject","parameters":[{"in":"path","name":"projectId","required":true,"schema":{"pattern":"^[0-9A-HJKMNP-TV-Z]{26}$","type":"string"}}],"responses":{"204":{"description":"No Content"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Conflict"},"421":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Misdirected Request"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Delete a project","tags":["projects"]},"get":{"operationId":"getProject","parameters":[{"in":"path","name":"projectId","required":true,"schema":{"pattern":"^[0-9A-HJKMNP-TV-Z]{26}$","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Project"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Get a project by id","tags":["projects"]}},"/projects/{projectId}/grants":{"post":{"operationId":"grantProjectAccess","parameters":[{"in":"path","name":"projectId","required":true,"schema":{"pattern":"^[0-9A-HJKMNP-TV-Z]{26}$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GrantProjectAccessInputBody"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GrantProjectAccessOutputBody"}}},"description":"Created"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Grant project access to an identity","tags":["projects"]}},"/projects/{projectId}/grants/account/{provider}/{providerUserId}":{"delete":{"operationId":"revokeProjectAccessByProvider","parameters":[{"in":"path","name":"projectId","required":true,"schema":{"pattern":"^[0-9A-HJKMNP-TV-Z]{26}$","type":"string"}},{"in":"path","name":"provider","required":true,"schema":{"minLength":1,"type":"string"}},{"in":"path","name":"providerUserId","required":true,"schema":{"minLength":1,"type":"string"}}],"responses":{"204":{"description":"No Content"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Revoke project access by provider identity","tags":["projects"]}},"/projects/{projectId}/grants/{granteeType}/{granteeId}":{"delete":{"operationId":"revokeProjectAccess","parameters":[{"in":"path","name":"projectId","required":true,"schema":{"pattern":"^[0-9A-HJKMNP-TV-Z]{26}$","type":"string"}},{"in":"path","name":"granteeType","required":true,"schema":{"minLength":1,"type":"string"}},{"in":"path","name":"granteeId","required":true,"schema":{"minLength":1,"type":"string"}}],"responses":{"204":{"description":"No Content"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Revoke project access by grantee id","tags":["projects"]}},"/projects/{projectId}/members":{"get":{"operationId":"listProjectMembers","parameters":[{"description":"Maximum entries to return; server may cap further.","explode":false,"in":"query","name":"pageSize","schema":{"description":"Maximum entries to return; server may cap further.","format":"int32","maximum":500,"minimum":0,"type":"integer"}},{"description":"Opaque cursor from a previous response's nextPageToken.","explode":false,"in":"query","name":"pageToken","schema":{"description":"Opaque cursor from a previous response's nextPageToken.","type":"string"}},{"in":"path","name":"projectId","required":true,"schema":{"pattern":"^[0-9A-HJKMNP-TV-Z]{26}$","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListProjectMembersOutputBody"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"List project members and their roles","tags":["projects"]}},"/projects/{projectId}/repos":{"get":{"operationId":"listProjectRepos","parameters":[{"description":"Maximum entries to return; server may cap further.","explode":false,"in":"query","name":"pageSize","schema":{"description":"Maximum entries to return; server may cap further.","format":"int32","maximum":500,"minimum":0,"type":"integer"}},{"description":"Opaque cursor from a previous response's nextPageToken.","explode":false,"in":"query","name":"pageToken","schema":{"description":"Opaque cursor from a previous response's nextPageToken.","type":"string"}},{"in":"path","name":"projectId","required":true,"schema":{"pattern":"^[0-9A-HJKMNP-TV-Z]{26}$","type":"string"}},{"description":"Optional: exact-match repo name.","explode":false,"in":"query","name":"name","schema":{"description":"Optional: exact-match repo name.","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListProjectReposOutputBody"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"List repositories in a project (or one by name)","tags":["repos"]}},"/repos":{"post":{"operationId":"createRepo","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateRepoInputBody"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Repo"}}},"description":"Created"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Create repository","tags":["repos"]}},"/repos/{repoId}":{"delete":{"operationId":"deleteRepo","parameters":[{"in":"path","name":"repoId","required":true,"schema":{"pattern":"^[0-9A-HJKMNP-TV-Z]{26}$","type":"string"}}],"responses":{"204":{"description":"No Content"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"421":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Misdirected Request"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"502":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Gateway"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Delete repository","tags":["repos"]},"get":{"operationId":"getRepo","parameters":[{"in":"path","name":"repoId","required":true,"schema":{"pattern":"^[0-9A-HJKMNP-TV-Z]{26}$","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Repo"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Get repository","tags":["repos"]}},"/repos/{repoId}/grants":{"get":{"operationId":"listRepoGrants","parameters":[{"description":"Maximum entries to return; server may cap further.","explode":false,"in":"query","name":"pageSize","schema":{"description":"Maximum entries to return; server may cap further.","format":"int32","maximum":500,"minimum":0,"type":"integer"}},{"description":"Opaque cursor from a previous response's nextPageToken.","explode":false,"in":"query","name":"pageToken","schema":{"description":"Opaque cursor from a previous response's nextPageToken.","type":"string"}},{"in":"path","name":"repoId","required":true,"schema":{"pattern":"^[0-9A-HJKMNP-TV-Z]{26}$","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListRepoGrantsOutputBody"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"List repo grants","tags":["repos"]},"post":{"operationId":"grantRepoAccess","parameters":[{"in":"path","name":"repoId","required":true,"schema":{"pattern":"^[0-9A-HJKMNP-TV-Z]{26}$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GrantRepoAccessInputBody"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GrantRepoAccessOutputBody"}}},"description":"Created"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Grant repo access to an identity","tags":["repos"]}},"/repos/{repoId}/grants/account/{provider}/{providerUserId}":{"delete":{"operationId":"revokeRepoAccessByProvider","parameters":[{"in":"path","name":"repoId","required":true,"schema":{"pattern":"^[0-9A-HJKMNP-TV-Z]{26}$","type":"string"}},{"in":"path","name":"provider","required":true,"schema":{"minLength":1,"type":"string"}},{"in":"path","name":"providerUserId","required":true,"schema":{"minLength":1,"type":"string"}}],"responses":{"204":{"description":"No Content"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Revoke repo access by provider identity","tags":["repos"]}},"/repos/{repoId}/grants/{granteeType}/{granteeId}":{"delete":{"operationId":"revokeRepoAccess","parameters":[{"in":"path","name":"repoId","required":true,"schema":{"pattern":"^[0-9A-HJKMNP-TV-Z]{26}$","type":"string"}},{"in":"path","name":"granteeType","required":true,"schema":{"minLength":1,"type":"string"}},{"in":"path","name":"granteeId","required":true,"schema":{"minLength":1,"type":"string"}}],"responses":{"204":{"description":"No Content"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Revoke repo access by grantee id","tags":["repos"]}},"/service-accounts":{"get":{"operationId":"listServiceAccounts","parameters":[{"description":"Maximum entries to return; server may cap further.","explode":false,"in":"query","name":"pageSize","schema":{"description":"Maximum entries to return; server may cap further.","format":"int32","maximum":500,"minimum":0,"type":"integer"}},{"description":"Opaque cursor from a previous response's nextPageToken.","explode":false,"in":"query","name":"pageToken","schema":{"description":"Opaque cursor from a previous response's nextPageToken.","type":"string"}},{"explode":false,"in":"query","name":"orgId","required":true,"schema":{"pattern":"^[0-9A-HJKMNP-TV-Z]{26}$","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListServiceAccountsOutputBody"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"List service accounts in an org","tags":["service-accounts"]},"post":{"operationId":"createServiceAccount","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateServiceAccountInputBody"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServiceAccount"}}},"description":"Created"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Create service account","tags":["service-accounts"]}},"/service-accounts/{accountId}":{"delete":{"operationId":"deleteServiceAccount","parameters":[{"in":"path","name":"accountId","required":true,"schema":{"minLength":1,"type":"string"}}],"responses":{"204":{"description":"No Content"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Delete service account","tags":["service-accounts"]},"get":{"operationId":"getServiceAccount","parameters":[{"in":"path","name":"accountId","required":true,"schema":{"minLength":1,"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServiceAccount"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Get service account","tags":["service-accounts"]}},"/service-accounts/{accountId}/bindings":{"get":{"operationId":"listBindings","parameters":[{"description":"Maximum entries to return; server may cap further.","explode":false,"in":"query","name":"pageSize","schema":{"description":"Maximum entries to return; server may cap further.","format":"int32","maximum":500,"minimum":0,"type":"integer"}},{"description":"Opaque cursor from a previous response's nextPageToken.","explode":false,"in":"query","name":"pageToken","schema":{"description":"Opaque cursor from a previous response's nextPageToken.","type":"string"}},{"in":"path","name":"accountId","required":true,"schema":{"minLength":1,"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListBindingsOutputBody"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"List OIDC bindings","tags":["service-accounts"]},"post":{"operationId":"createBinding","parameters":[{"in":"path","name":"accountId","required":true,"schema":{"minLength":1,"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateBindingInputBody"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Binding"}}},"description":"Created"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Create OIDC binding","tags":["service-accounts"]}},"/service-accounts/{accountId}/bindings/{bindingId}":{"delete":{"operationId":"deleteBinding","parameters":[{"in":"path","name":"accountId","required":true,"schema":{"minLength":1,"type":"string"}},{"in":"path","name":"bindingId","required":true,"schema":{"minLength":1,"type":"string"}}],"responses":{"204":{"description":"No Content"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Delete OIDC binding","tags":["service-accounts"]}},"/service-accounts/{accountId}/grants":{"get":{"operationId":"listServiceAccountGrants","parameters":[{"description":"Maximum entries to return; server may cap further.","explode":false,"in":"query","name":"pageSize","schema":{"description":"Maximum entries to return; server may cap further.","format":"int32","maximum":500,"minimum":0,"type":"integer"}},{"description":"Opaque cursor from a previous response's nextPageToken.","explode":false,"in":"query","name":"pageToken","schema":{"description":"Opaque cursor from a previous response's nextPageToken.","type":"string"}},{"in":"path","name":"accountId","required":true,"schema":{"minLength":1,"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListServiceAccountGrantsOutputBody"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"List service account grants","tags":["service-accounts"]},"post":{"operationId":"grantServiceAccountAccess","parameters":[{"in":"path","name":"accountId","required":true,"schema":{"minLength":1,"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GrantServiceAccountAccessInputBody"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GrantServiceAccountAccessOutputBody"}}},"description":"Created"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Grant service account access on a repo or project","tags":["service-accounts"]}},"/service-accounts/{accountId}/grants/{resourceType}/{resourceId}":{"delete":{"operationId":"revokeServiceAccountAccess","parameters":[{"in":"path","name":"accountId","required":true,"schema":{"minLength":1,"type":"string"}},{"in":"path","name":"resourceType","required":true,"schema":{"enum":["repo","project"],"type":"string"}},{"in":"path","name":"resourceId","required":true,"schema":{"minLength":1,"type":"string"}}],"responses":{"204":{"description":"No Content"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Revoke service account access","tags":["service-accounts"]}},"/version":{"get":{"operationId":"getVersion","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetVersionOutputBody"}}},"description":"OK"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Get the server version and mode","tags":["meta"]}}},"servers":[{"url":"/api/v1"}]} \ No newline at end of file +{"components":{"schemas":{"AddOrgMemberInputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/AddOrgMemberInputBody.json"],"format":"uri","readOnly":true,"type":"string"},"provider":{"minLength":1,"type":"string"},"providerUserId":{"minLength":1,"type":"string"},"role":{"default":"member","description":"Role at the org; defaults to member.","enum":["owner","admin","member"],"type":"string"}},"required":["provider","providerUserId"],"type":"object"},"AuditEvent":{"additionalProperties":true,"properties":{"actorId":{"type":"string"},"eventType":{"type":"string"},"id":{"type":"string"},"ipAddress":{"description":"Source IP recorded when the event was logged.","type":"string"},"metadata":{"additionalProperties":{},"type":"object"},"occurredAt":{"format":"date-time","type":"string"}},"required":["id","occurredAt","eventType","actorId"],"type":"object"},"AvailableMirror":{"additionalProperties":true,"properties":{"access":{"description":"Caller's effective GitHub access: read, write, or admin.","enum":["read","write","admin"],"type":"string"},"isArchived":{"type":"boolean"},"isPrivate":{"type":"boolean"},"owner":{"type":"string"},"repo":{"type":"string"},"status":{"description":"available (can onboard), mirrored (already mirrored), or owner-only (personal repo of another user).","enum":["available","mirrored","owner-only"],"type":"string"}},"required":["owner","repo","access","status"],"type":"object"},"BatchLookupInputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/BatchLookupInputBody.json"],"format":"uri","readOnly":true,"type":"string"},"refs":{"items":{"$ref":"#/components/schemas/LookupRef"},"maxItems":100,"minItems":1,"type":"array"}},"required":["refs"],"type":"object"},"BatchLookupOutputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/BatchLookupOutputBody.json"],"format":"uri","readOnly":true,"type":"string"},"refs":{"items":{"$ref":"#/components/schemas/LookupRefResult"},"type":"array"}},"required":["refs"],"type":"object"},"Binding":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/Binding.json"],"format":"uri","readOnly":true,"type":"string"},"accountId":{"type":"string"},"attributeFilter":{},"createdAt":{"format":"date-time","type":"string"},"id":{"type":"string"},"providerId":{"type":"string"}},"required":["id","accountId","providerId","attributeFilter","createdAt"],"type":"object"},"Cluster":{"additionalProperties":true,"properties":{"isDefault":{"type":"boolean"},"jurisdiction":{"type":"string"},"publicUrl":{"type":"string"},"slug":{"type":"string"}},"required":["slug","jurisdiction","publicUrl","isDefault"],"type":"object"},"CreateBindingInputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/CreateBindingInputBody.json"],"format":"uri","readOnly":true,"type":"string"},"attributeFilter":{"description":"Exact-match key/value map; empty filter matches any token."},"providerId":{"minLength":1,"type":"string"}},"required":["providerId"],"type":"object"},"CreateMirrorInputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/CreateMirrorInputBody.json"],"format":"uri","readOnly":true,"type":"string"},"clusterHost":{"description":"DNS host of the destination cluster.","minLength":1,"type":"string"},"owner":{"minLength":1,"type":"string"},"provider":{"enum":["github"],"type":"string"},"repo":{"minLength":1,"type":"string"}},"required":["provider","owner","repo","clusterHost"],"type":"object"},"CreateOrgInputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/CreateOrgInputBody.json"],"format":"uri","readOnly":true,"type":"string"},"name":{"description":"Display name.","maxLength":100,"minLength":1,"type":"string"},"region":{"description":"Jurisdiction slug; defaults to the server's home jurisdiction.","type":"string"}},"required":["name"],"type":"object"},"CreateProjectInputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/CreateProjectInputBody.json"],"format":"uri","readOnly":true,"type":"string"},"name":{"maxLength":100,"minLength":1,"type":"string"},"ownerId":{"minLength":1,"type":"string"},"ownerType":{"enum":["org","account"],"type":"string"},"region":{"type":"string"}},"required":["name","ownerType","ownerId"],"type":"object"},"CreateRepoInputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/CreateRepoInputBody.json"],"format":"uri","readOnly":true,"type":"string"},"clusterHost":{"description":"Public host of the cluster to pin the repo to (e.g. royalcanin.partial.to); empty lands on the jurisdiction default.","type":"string"},"name":{"minLength":1,"type":"string"},"objectFormat":{"description":"Hash format; defaults to sha1.","enum":["sha1","sha256"],"type":"string"},"projectId":{"pattern":"^[0-9A-HJKMNP-TV-Z]{26}$","type":"string"}},"required":["projectId","name"],"type":"object"},"CreateServiceAccountInputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/CreateServiceAccountInputBody.json"],"format":"uri","readOnly":true,"type":"string"},"name":{"minLength":1,"type":"string"},"orgId":{"pattern":"^[0-9A-HJKMNP-TV-Z]{26}$","type":"string"}},"required":["orgId","name"],"type":"object"},"CreatedMirror":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/CreatedMirror.json"],"format":"uri","readOnly":true,"type":"string"},"created":{"description":"true on fresh creation; false when an existing mirror was returned.","type":"boolean"},"empty":{"description":"true when the upstream has no refs to clone.","type":"boolean"},"mirrorId":{"type":"string"},"mirrorUrl":{"type":"string"},"publicUrl":{"type":"string"}},"required":["mirrorId","mirrorUrl","publicUrl","created","empty"],"type":"object"},"ErrorDetail":{"additionalProperties":true,"properties":{"location":{"description":"Where the error occurred, e.g. 'body.items[3].tags' or 'path.thing-id'","type":"string"},"message":{"description":"Error message text","type":"string"},"value":{"description":"The value at the given location"}},"type":"object"},"ErrorModel":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/ErrorModel.json"],"format":"uri","readOnly":true,"type":"string"},"detail":{"description":"A human-readable explanation specific to this occurrence of the problem.","examples":["Property foo is required but is missing."],"type":"string"},"errors":{"description":"Optional list of individual error details","items":{"$ref":"#/components/schemas/ErrorDetail"},"type":"array"},"instance":{"description":"A URI reference that identifies the specific occurrence of the problem.","examples":["https://example.com/error-log/abc123"],"format":"uri","type":"string"},"status":{"description":"HTTP status code","examples":[400],"format":"int64","type":"integer"},"title":{"description":"A short, human-readable summary of the problem type. This value should not change between occurrences of the error.","examples":["Bad Request"],"type":"string"},"type":{"default":"about:blank","description":"A URI reference to human-readable documentation for the error.","examples":["https://example.com/errors/example"],"format":"uri","type":"string"}},"type":"object"},"GetMeOutputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/GetMeOutputBody.json"],"format":"uri","readOnly":true,"type":"string"},"auth":{"$ref":"#/components/schemas/MeAuth"},"global":{"$ref":"#/components/schemas/MeGlobal"},"jurisdiction":{"type":"string"},"mode":{"enum":["standalone","global","regional"],"type":"string"},"regional":{"$ref":"#/components/schemas/MeRegional"},"regionalUnavailable":{"$ref":"#/components/schemas/MeRegionalUnavailable"}},"required":["global","auth"],"type":"object"},"GetPermissionsOutputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/GetPermissionsOutputBody.json"],"format":"uri","readOnly":true,"type":"string"},"explain":{"additionalProperties":{},"type":"object"},"permissions":{"items":{"type":"string"},"type":"array"},"resourceId":{"type":"string"},"resourceType":{"type":"string"}},"required":["resourceType","resourceId"],"type":"object"},"GetVersionOutputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/GetVersionOutputBody.json"],"format":"uri","readOnly":true,"type":"string"},"mode":{"description":"Server mode.","enum":["standalone","global","regional"],"type":"string"},"version":{"description":"Git commit SHA of the running entire-core binary, or \"dev\" for an untagged local build.","type":"string"}},"required":["version"],"type":"object"},"GrantMirrorCollaboratorInputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/GrantMirrorCollaboratorInputBody.json"],"format":"uri","readOnly":true,"type":"string"},"clusterHost":{"description":"Public host of the cluster serving the mirror.","minLength":1,"type":"string"},"handle":{"description":"Qualified grantee handle, e.g. github:alice.","minLength":1,"type":"string"},"owner":{"minLength":1,"type":"string"},"provider":{"enum":["github"],"type":"string"},"repo":{"minLength":1,"type":"string"},"role":{"description":"Grant level: reader (pull) or writer (pull+push).","enum":["reader","writer"],"type":"string"}},"required":["provider","owner","repo","clusterHost","handle","role"],"type":"object"},"GrantProjectAccessInputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/GrantProjectAccessInputBody.json"],"format":"uri","readOnly":true,"type":"string"},"granteeType":{"default":"account","enum":["account"],"type":"string"},"provider":{"minLength":1,"type":"string"},"providerUserId":{"minLength":1,"type":"string"},"role":{"enum":["reader","writer","admin"],"type":"string"}},"required":["provider","providerUserId","role"],"type":"object"},"GrantProjectAccessOutputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/GrantProjectAccessOutputBody.json"],"format":"uri","readOnly":true,"type":"string"},"status":{"type":"string"}},"required":["status"],"type":"object"},"GrantRepoAccessInputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/GrantRepoAccessInputBody.json"],"format":"uri","readOnly":true,"type":"string"},"granteeType":{"default":"account","enum":["account"],"type":"string"},"provider":{"minLength":1,"type":"string"},"providerUserId":{"minLength":1,"type":"string"},"role":{"enum":["reader","writer","admin"],"type":"string"}},"required":["provider","providerUserId","role"],"type":"object"},"GrantRepoAccessOutputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/GrantRepoAccessOutputBody.json"],"format":"uri","readOnly":true,"type":"string"},"status":{"type":"string"}},"required":["status"],"type":"object"},"GrantServiceAccountAccessInputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/GrantServiceAccountAccessInputBody.json"],"format":"uri","readOnly":true,"type":"string"},"resourceId":{"minLength":1,"type":"string"},"resourceType":{"enum":["repo","project"],"type":"string"},"role":{"enum":["reader","writer","admin"],"type":"string"}},"required":["resourceType","resourceId","role"],"type":"object"},"GrantServiceAccountAccessOutputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/GrantServiceAccountAccessOutputBody.json"],"format":"uri","readOnly":true,"type":"string"},"status":{"type":"string"}},"required":["status"],"type":"object"},"GrantedMirrorCollaborator":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/GrantedMirrorCollaborator.json"],"format":"uri","readOnly":true,"type":"string"},"accountId":{"description":"Entire account the grant was written for.","type":"string"},"role":{"type":"string"}},"required":["accountId","role"],"type":"object"},"ListAuditEventsOutputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/ListAuditEventsOutputBody.json"],"format":"uri","readOnly":true,"type":"string"},"events":{"items":{"$ref":"#/components/schemas/AuditEvent"},"type":"array"},"nextPageToken":{"description":"Pass back to fetch the next page; empty when no more entries.","type":"string"}},"required":["events"],"type":"object"},"ListAvailableMirrorsOutputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/ListAvailableMirrorsOutputBody.json"],"format":"uri","readOnly":true,"type":"string"},"available":{"items":{"$ref":"#/components/schemas/AvailableMirror"},"type":"array"}},"required":["available"],"type":"object"},"ListBindingsOutputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/ListBindingsOutputBody.json"],"format":"uri","readOnly":true,"type":"string"},"bindings":{"items":{"$ref":"#/components/schemas/Binding"},"type":"array"},"nextPageToken":{"description":"Pass back to fetch the next page; empty when no more entries.","type":"string"}},"required":["bindings"],"type":"object"},"ListClustersOutputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/ListClustersOutputBody.json"],"format":"uri","readOnly":true,"type":"string"},"clusters":{"items":{"$ref":"#/components/schemas/Cluster"},"type":"array"}},"required":["clusters"],"type":"object"},"ListMirrorCollaboratorsOutputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/ListMirrorCollaboratorsOutputBody.json"],"format":"uri","readOnly":true,"type":"string"},"collaborators":{"items":{"$ref":"#/components/schemas/MirrorCollaborator"},"type":"array"}},"required":["collaborators"],"type":"object"},"ListMirrorsOutputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/ListMirrorsOutputBody.json"],"format":"uri","readOnly":true,"type":"string"},"mirrors":{"items":{"$ref":"#/components/schemas/Mirror"},"type":"array"},"nextPageToken":{"description":"Pass back to fetch the next page; empty when no more entries.","type":"string"}},"required":["mirrors"],"type":"object"},"ListOIDCProvidersOutputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/ListOIDCProvidersOutputBody.json"],"format":"uri","readOnly":true,"type":"string"},"nextPageToken":{"description":"Pass back to fetch the next page; empty when no more entries.","type":"string"},"providers":{"items":{"$ref":"#/components/schemas/OIDCProvider"},"type":"array"}},"required":["providers"],"type":"object"},"ListOrgMembersOutputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/ListOrgMembersOutputBody.json"],"format":"uri","readOnly":true,"type":"string"},"members":{"items":{"$ref":"#/components/schemas/Membership"},"type":"array"},"nextPageToken":{"description":"Pass back to fetch the next page; empty when no more entries.","type":"string"}},"required":["members"],"type":"object"},"ListOrgProjectsOutputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/ListOrgProjectsOutputBody.json"],"format":"uri","readOnly":true,"type":"string"},"nextPageToken":{"description":"Pass back to fetch the next page; empty when no more entries.","type":"string"},"project":{"$ref":"#/components/schemas/Project"},"projects":{"items":{"$ref":"#/components/schemas/Project"},"type":"array"}},"type":"object"},"ListOrgsOutputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/ListOrgsOutputBody.json"],"format":"uri","readOnly":true,"type":"string"},"nextPageToken":{"description":"Pass back to fetch the next page; empty when no more entries.","type":"string"},"org":{"$ref":"#/components/schemas/Org"},"orgs":{"items":{"$ref":"#/components/schemas/Org"},"type":"array"}},"type":"object"},"ListProjectMembersOutputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/ListProjectMembersOutputBody.json"],"format":"uri","readOnly":true,"type":"string"},"members":{"items":{"$ref":"#/components/schemas/ProjectGrant"},"type":"array"},"nextPageToken":{"description":"Pass back to fetch the next page; empty when no more entries.","type":"string"}},"required":["members"],"type":"object"},"ListProjectReposOutputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/ListProjectReposOutputBody.json"],"format":"uri","readOnly":true,"type":"string"},"nextPageToken":{"description":"Pass back to fetch the next page; empty when no more entries.","type":"string"},"repo":{"$ref":"#/components/schemas/Repo"},"repos":{"items":{"$ref":"#/components/schemas/Repo"},"type":"array"}},"type":"object"},"ListProjectsOutputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/ListProjectsOutputBody.json"],"format":"uri","readOnly":true,"type":"string"},"nextPageToken":{"description":"Pass back to fetch the next page; empty when no more entries.","type":"string"},"project":{"$ref":"#/components/schemas/Project"},"projects":{"items":{"$ref":"#/components/schemas/Project"},"type":"array"}},"type":"object"},"ListRepoGrantsOutputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/ListRepoGrantsOutputBody.json"],"format":"uri","readOnly":true,"type":"string"},"grants":{"items":{"$ref":"#/components/schemas/RepoGrant"},"type":"array"},"nextPageToken":{"description":"Pass back to fetch the next page; empty when no more entries.","type":"string"}},"required":["grants"],"type":"object"},"ListServiceAccountGrantsOutputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/ListServiceAccountGrantsOutputBody.json"],"format":"uri","readOnly":true,"type":"string"},"grants":{"items":{"$ref":"#/components/schemas/ServiceAccountGrant"},"type":"array"},"nextPageToken":{"description":"Pass back to fetch the next page; empty when no more entries.","type":"string"}},"required":["grants"],"type":"object"},"ListServiceAccountsOutputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/ListServiceAccountsOutputBody.json"],"format":"uri","readOnly":true,"type":"string"},"nextPageToken":{"description":"Pass back to fetch the next page; empty when no more entries.","type":"string"},"serviceAccounts":{"items":{"$ref":"#/components/schemas/ServiceAccountWithGrants"},"type":"array"}},"required":["serviceAccounts"],"type":"object"},"LookupRef":{"additionalProperties":true,"properties":{"id":{"minLength":1,"type":"string"},"type":{"description":"Resource type slug; \"org\", \"project\", \"repo\" are enriched, unknown types pass through.","minLength":1,"type":"string"}},"required":["type","id"],"type":"object"},"LookupRefResult":{"additionalProperties":true,"properties":{"id":{"type":"string"},"name":{"type":"string"},"ownerId":{"type":"string"},"ownerType":{"enum":["org","account"],"type":"string"},"projectId":{"type":"string"},"type":{"type":"string"},"url":{"type":"string"}},"required":["type","id"],"type":"object"},"LookupResourcesOutputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/LookupResourcesOutputBody.json"],"format":"uri","readOnly":true,"type":"string"},"nextPageToken":{"description":"Pass back to fetch the next page; empty when no more entries.","type":"string"},"permission":{"type":"string"},"resourceIds":{"items":{"type":"string"},"type":"array"},"resourceType":{"type":"string"},"resources":{"items":{"$ref":"#/components/schemas/ResourceAccess"},"type":"array"}},"required":["resourceType"],"type":"object"},"MeAuth":{"additionalProperties":true,"properties":{"provider":{"type":"string"},"providerUserId":{"type":"string"}},"required":["provider","providerUserId"],"type":"object"},"MeGlobal":{"additionalProperties":true,"properties":{"accountId":{"type":"string"},"avatarUrl":{"type":"string"},"handle":{"type":"string"},"handles":{"items":{"$ref":"#/components/schemas/MeIdentityHandle"},"type":"array"},"homeJurisdiction":{"type":"string"}},"required":["accountId","handles"],"type":"object"},"MeIdentityHandle":{"additionalProperties":true,"properties":{"email":{"description":"The provider's publicly-visible profile email (from the provider at login; may be empty). NOT the account's contact email.","type":"string"},"handle":{"type":"string"},"provider":{"type":"string"},"providerUserId":{"type":"string"}},"required":["provider","handle","providerUserId"],"type":"object"},"MeRegional":{"additionalProperties":true,"properties":{"bio":{"type":"string"},"company":{"type":"string"},"displayName":{"type":"string"},"email":{"type":"string"},"location":{"type":"string"}},"type":"object"},"MeRegionalUnavailable":{"additionalProperties":true,"properties":{"error":{"description":"Always 'foreign_jurisdiction'. Discriminator for client-side state machines.","enum":["foreign_jurisdiction"],"type":"string"},"homeCoreUrl":{"description":"Deep link into the home console for this account.","type":"string"},"jurisdiction":{"description":"The account's home jurisdiction (e.g. 'us', 'eu').","type":"string"},"message":{"description":"Human-readable copy ready to surface in a UI.","type":"string"}},"required":["error","jurisdiction","homeCoreUrl","message"],"type":"object"},"Membership":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/Membership.json"],"format":"uri","readOnly":true,"type":"string"},"accountId":{"type":"string"},"createdAt":{"format":"date-time","type":"string"},"id":{"type":"string"},"orgId":{"type":"string"},"role":{"type":"string"},"status":{"type":"string"},"workosOrgMembershipId":{"type":"string"}},"required":["id","accountId","orgId","role","status","createdAt"],"type":"object"},"Mirror":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/Mirror.json"],"format":"uri","readOnly":true,"type":"string"},"cell":{"description":"Physical cell the mirror's cluster runs in, e.g. aws-us-east-2.","type":"string"},"clusterHost":{"description":"Public host of the cluster serving this mirror.","type":"string"},"createdAt":{"format":"date-time","type":"string"},"installationId":{"format":"int64","type":"integer"},"isArchived":{"type":"boolean"},"isPrivate":{"type":"boolean"},"jurisdiction":{"type":"string"},"mirrorId":{"type":"string"},"owner":{"type":"string"},"provider":{"type":"string"},"repo":{"type":"string"},"status":{"description":"Clone lifecycle: processing (cloning), ready (clonable), failed (initial clone failed), or suspended.","enum":["processing","ready","failed","suspended"],"type":"string"},"suspendedAt":{"format":"date-time","type":"string"}},"required":["mirrorId","provider","owner","repo","clusterHost","createdAt"],"type":"object"},"MirrorCollaborator":{"additionalProperties":true,"properties":{"accountId":{"type":"string"},"handle":{"description":"Primary handle (provider:label), empty if none resolves.","type":"string"},"role":{"description":"reader (pull) or writer (pull+push).","type":"string"}},"required":["accountId","role"],"type":"object"},"OIDCProvider":{"additionalProperties":true,"properties":{"description":{"type":"string"},"displayName":{"type":"string"},"id":{"type":"string"},"issuer":{"type":"string"}},"required":["id","issuer"],"type":"object"},"Org":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/Org.json"],"format":"uri","readOnly":true,"type":"string"},"createdAt":{"format":"date-time","type":"string"},"id":{"type":"string"},"name":{"type":"string"},"region":{"type":"string"},"workosOrganizationId":{"type":"string"}},"required":["id","name","region","createdAt"],"type":"object"},"Project":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/Project.json"],"format":"uri","readOnly":true,"type":"string"},"createdAt":{"format":"date-time","type":"string"},"id":{"type":"string"},"name":{"type":"string"},"ownerId":{"type":"string"},"ownerType":{"enum":["org","account"],"type":"string"},"region":{"type":"string"}},"required":["id","name","ownerType","ownerId","region","createdAt"],"type":"object"},"ProjectGrant":{"additionalProperties":true,"properties":{"granteeId":{"type":"string"},"granteeType":{"type":"string"},"role":{"type":"string"}},"required":["granteeType","granteeId","role"],"type":"object"},"Repo":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/Repo.json"],"format":"uri","readOnly":true,"type":"string"},"clusterHost":{"type":"string"},"foreign":{"type":"boolean"},"id":{"type":"string"},"mirrorSuspended":{"type":"boolean"},"mirrorSuspendedAt":{"type":"string"},"name":{"type":"string"},"objectFormat":{"enum":["sha1","sha256"],"type":"string"},"owningProjectId":{"type":"string"},"path":{"type":"string"},"provisionAttempts":{"format":"int64","type":"integer"},"provisionReason":{"type":"string"},"state":{"enum":["provisioning","active","failed"],"type":"string"}},"required":["id","owningProjectId","name"],"type":"object"},"RepoGrant":{"additionalProperties":true,"properties":{"granteeId":{"type":"string"},"granteeType":{"type":"string"},"role":{"type":"string"}},"required":["granteeType","granteeId","role"],"type":"object"},"ResolvedIdentity":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/ResolvedIdentity.json"],"format":"uri","readOnly":true,"type":"string"},"accountId":{"type":"string"},"handle":{"type":"string"},"provider":{"type":"string"},"providerUserId":{"type":"string"}},"required":["accountId","provider","handle","providerUserId"],"type":"object"},"ResourceAccess":{"additionalProperties":true,"properties":{"permissions":{"items":{"type":"string"},"type":"array"},"resourceId":{"type":"string"}},"required":["resourceId","permissions"],"type":"object"},"ServiceAccount":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/ServiceAccount.json"],"format":"uri","readOnly":true,"type":"string"},"accountId":{"type":"string"},"createdAt":{"format":"date-time","type":"string"},"name":{"type":"string"},"orgId":{"type":"string"},"status":{"type":"string"},"systemManaged":{"type":"boolean"}},"required":["accountId","name","orgId","status","systemManaged","createdAt"],"type":"object"},"ServiceAccountGrant":{"additionalProperties":true,"properties":{"resourceId":{"type":"string"},"resourceName":{"type":"string"},"resourceType":{"type":"string"},"role":{"type":"string"}},"required":["resourceType","resourceId","role"],"type":"object"},"ServiceAccountWithGrants":{"additionalProperties":true,"properties":{"accountId":{"type":"string"},"createdAt":{"format":"date-time","type":"string"},"grants":{"items":{"type":"string"},"type":"array"},"name":{"type":"string"},"orgId":{"type":"string"},"status":{"type":"string"},"systemManaged":{"type":"boolean"}},"required":["grants","accountId","name","orgId","status","systemManaged","createdAt"],"type":"object"},"UpdateMeInputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/UpdateMeInputBody.json"],"format":"uri","readOnly":true,"type":"string"},"email":{"description":"Contact email.","format":"email","maxLength":254,"minLength":5,"type":"string"}},"required":["email"],"type":"object"},"UpdateMeOutputBody":{"additionalProperties":true,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["/api/v1/schemas/UpdateMeOutputBody.json"],"format":"uri","readOnly":true,"type":"string"},"email":{"type":"string"}},"required":["email"],"type":"object"}},"securitySchemes":{"bearerAuth":{"bearerFormat":"JWT","description":"Bearer token minted by entire-core's device-code flow or STS exchange.","scheme":"bearer","type":"http"},"sessionAuth":{"description":"Console session cookie issued by the browser login flow.","in":"cookie","name":"entire_session","type":"apiKey"}}},"info":{"description":"Entire control plane: identity, orgs, projects, repos, mirrors, service accounts.","title":"Entire Core API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/access/{resourceType}":{"get":{"operationId":"lookupResources","parameters":[{"description":"Maximum entries to return; server may cap further.","explode":false,"in":"query","name":"pageSize","schema":{"description":"Maximum entries to return; server may cap further.","format":"int32","maximum":500,"minimum":0,"type":"integer"}},{"description":"Opaque cursor from a previous response's nextPageToken.","explode":false,"in":"query","name":"pageToken","schema":{"description":"Opaque cursor from a previous response's nextPageToken.","type":"string"}},{"description":"SpiceDB resource type (e.g. \"repo\", \"project\", \"org\").","in":"path","name":"resourceType","required":true,"schema":{"description":"SpiceDB resource type (e.g. \"repo\", \"project\", \"org\").","minLength":1,"type":"string"}},{"description":"Optional: only list resources where the caller has this permission. pageSize/pageToken apply only when set.","explode":false,"in":"query","name":"permission","schema":{"description":"Optional: only list resources where the caller has this permission. pageSize/pageToken apply only when set.","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LookupResourcesOutputBody"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"List resources of a type the caller can access","tags":["identity"]}},"/access/{resourceType}/{resourceId}":{"get":{"operationId":"getPermissions","parameters":[{"in":"path","name":"resourceType","required":true,"schema":{"minLength":1,"type":"string"}},{"in":"path","name":"resourceId","required":true,"schema":{"minLength":1,"type":"string"}},{"description":"If set, return the SpiceDB trace for this permission instead of the permission list.","explode":false,"in":"query","name":"explain","schema":{"description":"If set, return the SpiceDB trace for this permission instead of the permission list.","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetPermissionsOutputBody"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"List the caller's permissions on a single resource","tags":["identity"]}},"/audit":{"get":{"operationId":"listAuditEvents","parameters":[{"description":"Maximum entries to return; server may cap further.","explode":false,"in":"query","name":"pageSize","schema":{"description":"Maximum entries to return; server may cap further.","format":"int32","maximum":500,"minimum":0,"type":"integer"}},{"description":"Opaque cursor from a previous response's nextPageToken.","explode":false,"in":"query","name":"pageToken","schema":{"description":"Opaque cursor from a previous response's nextPageToken.","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListAuditEventsOutputBody"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"List the calling account's recent audit events","tags":["identity"]}},"/clusters":{"get":{"operationId":"listClusters","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListClustersOutputBody"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"List data-plane clusters attached to this control plane","tags":["clusters"]}},"/identity/handles/{provider}/{handle}":{"get":{"operationId":"resolveHandle","parameters":[{"description":"IdP slug (e.g. \"github\").","in":"path","name":"provider","required":true,"schema":{"description":"IdP slug (e.g. \"github\").","minLength":1,"type":"string"}},{"description":"User-visible handle at the provider.","in":"path","name":"handle","required":true,"schema":{"description":"User-visible handle at the provider.","minLength":1,"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ResolvedIdentity"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Resolve account by external provider handle","tags":["identity"]}},"/lookup":{"post":{"operationId":"batchLookup","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BatchLookupInputBody"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BatchLookupOutputBody"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Batch-resolve (type, id) refs to enriched records","tags":["identity"]}},"/me":{"get":{"operationId":"getMe","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetMeOutputBody"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Get the calling account's identity and profile","tags":["identity"]},"patch":{"operationId":"updateMe","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateMeInputBody"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateMeOutputBody"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Update the calling account's contact email","tags":["identity"]}},"/mirrors":{"delete":{"operationId":"deleteMirror","parameters":[{"explode":false,"in":"query","name":"provider","required":true,"schema":{"enum":["github"],"type":"string"}},{"explode":false,"in":"query","name":"owner","required":true,"schema":{"minLength":1,"type":"string"}},{"explode":false,"in":"query","name":"repo","required":true,"schema":{"minLength":1,"type":"string"}},{"description":"Public host of the cluster serving the mirror.","explode":false,"in":"query","name":"clusterHost","required":true,"schema":{"description":"Public host of the cluster serving the mirror.","minLength":1,"type":"string"}}],"responses":{"204":{"description":"No Content"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"421":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Misdirected Request"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"502":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Gateway"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Delete a mirror by upstream coords + cluster host","tags":["mirrors"]},"get":{"operationId":"listMirrors","parameters":[{"description":"Maximum entries to return; server may cap further.","explode":false,"in":"query","name":"pageSize","schema":{"description":"Maximum entries to return; server may cap further.","format":"int32","maximum":500,"minimum":0,"type":"integer"}},{"description":"Opaque cursor from a previous response's nextPageToken.","explode":false,"in":"query","name":"pageToken","schema":{"description":"Opaque cursor from a previous response's nextPageToken.","type":"string"}},{"description":"Optional: restrict to mirrors on this cluster (public host, e.g. royalcanin.partial.to).","explode":false,"in":"query","name":"cluster","schema":{"description":"Optional: restrict to mirrors on this cluster (public host, e.g. royalcanin.partial.to).","type":"string"}},{"description":"Optional: restrict to mirrors of this upstream provider (e.g. \"github\").","explode":false,"in":"query","name":"provider","schema":{"description":"Optional: restrict to mirrors of this upstream provider (e.g. \"github\").","type":"string"}},{"description":"Optional: restrict to mirrors with this upstream owner login.","explode":false,"in":"query","name":"owner","schema":{"description":"Optional: restrict to mirrors with this upstream owner login.","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListMirrorsOutputBody"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"List mirrors visible to the caller","tags":["mirrors"]},"post":{"operationId":"createMirror","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateMirrorInputBody"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreatedMirror"}}},"description":"Created"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Conflict"},"412":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Precondition Failed"},"421":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Misdirected Request"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"502":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Gateway"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Create GitHub mirror","tags":["mirrors"]}},"/mirrors/available":{"get":{"operationId":"listAvailableMirrors","parameters":[{"description":"Optional: restrict to repos with this owner login.","explode":false,"in":"query","name":"owner","schema":{"description":"Optional: restrict to repos with this owner login.","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListAvailableMirrorsOutputBody"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"502":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Gateway"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"List GitHub repos the caller could onboard as mirrors","tags":["mirrors"]}},"/mirrors/collaborators":{"delete":{"operationId":"revokeMirrorCollaborator","parameters":[{"explode":false,"in":"query","name":"provider","required":true,"schema":{"enum":["github"],"type":"string"}},{"explode":false,"in":"query","name":"owner","required":true,"schema":{"minLength":1,"type":"string"}},{"explode":false,"in":"query","name":"repo","required":true,"schema":{"minLength":1,"type":"string"}},{"description":"Public host of the cluster serving the mirror.","explode":false,"in":"query","name":"clusterHost","required":true,"schema":{"description":"Public host of the cluster serving the mirror.","minLength":1,"type":"string"}},{"description":"Qualified grantee handle, e.g. github:alice.","explode":false,"in":"query","name":"handle","required":true,"schema":{"description":"Qualified grantee handle, e.g. github:alice.","minLength":1,"type":"string"}}],"responses":{"204":{"description":"No Content"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"421":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Misdirected Request"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"502":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Gateway"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Revoke a user's access to a mirror (live GitHub-admin gated)","tags":["mirrors"]},"get":{"operationId":"listMirrorCollaborators","parameters":[{"explode":false,"in":"query","name":"provider","required":true,"schema":{"enum":["github"],"type":"string"}},{"explode":false,"in":"query","name":"owner","required":true,"schema":{"minLength":1,"type":"string"}},{"explode":false,"in":"query","name":"repo","required":true,"schema":{"minLength":1,"type":"string"}},{"description":"Public host of the cluster serving the mirror.","explode":false,"in":"query","name":"clusterHost","required":true,"schema":{"description":"Public host of the cluster serving the mirror.","minLength":1,"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListMirrorCollaboratorsOutputBody"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"421":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Misdirected Request"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"502":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Gateway"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"List the principals with access to a mirror (live GitHub-admin gated)","tags":["mirrors"]},"post":{"operationId":"grantMirrorCollaborator","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GrantMirrorCollaboratorInputBody"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GrantedMirrorCollaborator"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Conflict"},"421":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Misdirected Request"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"502":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Gateway"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Grant a user reader/writer access to a mirror (live GitHub-admin gated)","tags":["mirrors"]}},"/mirrors/{mirrorId}":{"get":{"operationId":"getMirror","parameters":[{"in":"path","name":"mirrorId","required":true,"schema":{"pattern":"^[0-9A-HJKMNP-TV-Z]{26}$","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Mirror"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Get mirror by id","tags":["mirrors"]}},"/oidc-providers":{"get":{"operationId":"listOIDCProviders","parameters":[{"description":"Maximum entries to return; server may cap further.","explode":false,"in":"query","name":"pageSize","schema":{"description":"Maximum entries to return; server may cap further.","format":"int32","maximum":500,"minimum":0,"type":"integer"}},{"description":"Opaque cursor from a previous response's nextPageToken.","explode":false,"in":"query","name":"pageToken","schema":{"description":"Opaque cursor from a previous response's nextPageToken.","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListOIDCProvidersOutputBody"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"List federated OIDC identity providers","tags":["identity"]}},"/orgs":{"get":{"operationId":"listOrgs","parameters":[{"description":"Maximum entries to return; server may cap further.","explode":false,"in":"query","name":"pageSize","schema":{"description":"Maximum entries to return; server may cap further.","format":"int32","maximum":500,"minimum":0,"type":"integer"}},{"description":"Opaque cursor from a previous response's nextPageToken.","explode":false,"in":"query","name":"pageToken","schema":{"description":"Opaque cursor from a previous response's nextPageToken.","type":"string"}},{"description":"Optional: exact-match org name.","explode":false,"in":"query","name":"name","schema":{"description":"Optional: exact-match org name.","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListOrgsOutputBody"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"List organizations the caller can see (or one by name)","tags":["orgs"]},"post":{"operationId":"createOrg","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateOrgInputBody"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Org"}}},"description":"Created"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Create organization","tags":["orgs"]}},"/orgs/{orgId}":{"delete":{"operationId":"deleteOrg","parameters":[{"in":"path","name":"orgId","required":true,"schema":{"pattern":"^[0-9A-HJKMNP-TV-Z]{26}$","type":"string"}}],"responses":{"204":{"description":"No Content"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Conflict"},"421":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Misdirected Request"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Delete an organization","tags":["orgs"]},"get":{"operationId":"getOrg","parameters":[{"in":"path","name":"orgId","required":true,"schema":{"pattern":"^[0-9A-HJKMNP-TV-Z]{26}$","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Org"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Get an organization","tags":["orgs"]}},"/orgs/{orgId}/members":{"get":{"operationId":"listOrgMembers","parameters":[{"description":"Maximum entries to return; server may cap further.","explode":false,"in":"query","name":"pageSize","schema":{"description":"Maximum entries to return; server may cap further.","format":"int32","maximum":500,"minimum":0,"type":"integer"}},{"description":"Opaque cursor from a previous response's nextPageToken.","explode":false,"in":"query","name":"pageToken","schema":{"description":"Opaque cursor from a previous response's nextPageToken.","type":"string"}},{"in":"path","name":"orgId","required":true,"schema":{"pattern":"^[0-9A-HJKMNP-TV-Z]{26}$","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListOrgMembersOutputBody"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"List members of an organization","tags":["orgs"]},"post":{"operationId":"addOrgMember","parameters":[{"in":"path","name":"orgId","required":true,"schema":{"pattern":"^[0-9A-HJKMNP-TV-Z]{26}$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddOrgMemberInputBody"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Membership"}}},"description":"Created"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Add a member to an organization","tags":["orgs"]}},"/orgs/{orgId}/members/{provider}/{providerUserId}":{"delete":{"operationId":"removeOrgMember","parameters":[{"in":"path","name":"orgId","required":true,"schema":{"pattern":"^[0-9A-HJKMNP-TV-Z]{26}$","type":"string"}},{"in":"path","name":"provider","required":true,"schema":{"minLength":1,"type":"string"}},{"in":"path","name":"providerUserId","required":true,"schema":{"minLength":1,"type":"string"}}],"responses":{"204":{"description":"No Content"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Remove a member from an organization","tags":["orgs"]}},"/orgs/{orgId}/projects":{"get":{"operationId":"listOrgProjects","parameters":[{"description":"Maximum entries to return; server may cap further.","explode":false,"in":"query","name":"pageSize","schema":{"description":"Maximum entries to return; server may cap further.","format":"int32","maximum":500,"minimum":0,"type":"integer"}},{"description":"Opaque cursor from a previous response's nextPageToken.","explode":false,"in":"query","name":"pageToken","schema":{"description":"Opaque cursor from a previous response's nextPageToken.","type":"string"}},{"in":"path","name":"orgId","required":true,"schema":{"pattern":"^[0-9A-HJKMNP-TV-Z]{26}$","type":"string"}},{"description":"Optional: exact-match project name (case-insensitive).","explode":false,"in":"query","name":"name","schema":{"description":"Optional: exact-match project name (case-insensitive).","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListOrgProjectsOutputBody"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"List projects owned by an organization","tags":["projects"]}},"/projects":{"get":{"operationId":"listProjects","parameters":[{"description":"Maximum entries to return; server may cap further.","explode":false,"in":"query","name":"pageSize","schema":{"description":"Maximum entries to return; server may cap further.","format":"int32","maximum":500,"minimum":0,"type":"integer"}},{"description":"Opaque cursor from a previous response's nextPageToken.","explode":false,"in":"query","name":"pageToken","schema":{"description":"Opaque cursor from a previous response's nextPageToken.","type":"string"}},{"description":"Optional: exact-match project name.","explode":false,"in":"query","name":"name","schema":{"description":"Optional: exact-match project name.","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListProjectsOutputBody"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"List projects accessible to the caller (or one by name)","tags":["projects"]},"post":{"operationId":"createProject","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateProjectInputBody"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Project"}}},"description":"Created"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Create project","tags":["projects"]}},"/projects/{projectId}":{"delete":{"operationId":"deleteProject","parameters":[{"in":"path","name":"projectId","required":true,"schema":{"pattern":"^[0-9A-HJKMNP-TV-Z]{26}$","type":"string"}}],"responses":{"204":{"description":"No Content"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Conflict"},"421":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Misdirected Request"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Delete a project","tags":["projects"]},"get":{"operationId":"getProject","parameters":[{"in":"path","name":"projectId","required":true,"schema":{"pattern":"^[0-9A-HJKMNP-TV-Z]{26}$","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Project"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Get a project by id","tags":["projects"]}},"/projects/{projectId}/grants":{"post":{"operationId":"grantProjectAccess","parameters":[{"in":"path","name":"projectId","required":true,"schema":{"pattern":"^[0-9A-HJKMNP-TV-Z]{26}$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GrantProjectAccessInputBody"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GrantProjectAccessOutputBody"}}},"description":"Created"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Grant project access to an identity","tags":["projects"]}},"/projects/{projectId}/grants/account/{provider}/{providerUserId}":{"delete":{"operationId":"revokeProjectAccessByProvider","parameters":[{"in":"path","name":"projectId","required":true,"schema":{"pattern":"^[0-9A-HJKMNP-TV-Z]{26}$","type":"string"}},{"in":"path","name":"provider","required":true,"schema":{"minLength":1,"type":"string"}},{"in":"path","name":"providerUserId","required":true,"schema":{"minLength":1,"type":"string"}}],"responses":{"204":{"description":"No Content"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Revoke project access by provider identity","tags":["projects"]}},"/projects/{projectId}/grants/{granteeType}/{granteeId}":{"delete":{"operationId":"revokeProjectAccess","parameters":[{"in":"path","name":"projectId","required":true,"schema":{"pattern":"^[0-9A-HJKMNP-TV-Z]{26}$","type":"string"}},{"in":"path","name":"granteeType","required":true,"schema":{"minLength":1,"type":"string"}},{"in":"path","name":"granteeId","required":true,"schema":{"minLength":1,"type":"string"}}],"responses":{"204":{"description":"No Content"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Revoke project access by grantee id","tags":["projects"]}},"/projects/{projectId}/members":{"get":{"operationId":"listProjectMembers","parameters":[{"description":"Maximum entries to return; server may cap further.","explode":false,"in":"query","name":"pageSize","schema":{"description":"Maximum entries to return; server may cap further.","format":"int32","maximum":500,"minimum":0,"type":"integer"}},{"description":"Opaque cursor from a previous response's nextPageToken.","explode":false,"in":"query","name":"pageToken","schema":{"description":"Opaque cursor from a previous response's nextPageToken.","type":"string"}},{"in":"path","name":"projectId","required":true,"schema":{"pattern":"^[0-9A-HJKMNP-TV-Z]{26}$","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListProjectMembersOutputBody"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"List project members and their roles","tags":["projects"]}},"/projects/{projectId}/repos":{"get":{"operationId":"listProjectRepos","parameters":[{"description":"Maximum entries to return; server may cap further.","explode":false,"in":"query","name":"pageSize","schema":{"description":"Maximum entries to return; server may cap further.","format":"int32","maximum":500,"minimum":0,"type":"integer"}},{"description":"Opaque cursor from a previous response's nextPageToken.","explode":false,"in":"query","name":"pageToken","schema":{"description":"Opaque cursor from a previous response's nextPageToken.","type":"string"}},{"in":"path","name":"projectId","required":true,"schema":{"pattern":"^[0-9A-HJKMNP-TV-Z]{26}$","type":"string"}},{"description":"Optional: exact-match repo name.","explode":false,"in":"query","name":"name","schema":{"description":"Optional: exact-match repo name.","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListProjectReposOutputBody"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"List repositories in a project (or one by name)","tags":["repos"]}},"/repos":{"post":{"operationId":"createRepo","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateRepoInputBody"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Repo"}}},"description":"Created"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Create repository","tags":["repos"]}},"/repos/{repoId}":{"delete":{"operationId":"deleteRepo","parameters":[{"in":"path","name":"repoId","required":true,"schema":{"pattern":"^[0-9A-HJKMNP-TV-Z]{26}$","type":"string"}}],"responses":{"204":{"description":"No Content"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"421":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Misdirected Request"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"502":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Gateway"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Delete repository","tags":["repos"]},"get":{"operationId":"getRepo","parameters":[{"in":"path","name":"repoId","required":true,"schema":{"pattern":"^[0-9A-HJKMNP-TV-Z]{26}$","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Repo"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Get repository","tags":["repos"]}},"/repos/{repoId}/grants":{"get":{"operationId":"listRepoGrants","parameters":[{"description":"Maximum entries to return; server may cap further.","explode":false,"in":"query","name":"pageSize","schema":{"description":"Maximum entries to return; server may cap further.","format":"int32","maximum":500,"minimum":0,"type":"integer"}},{"description":"Opaque cursor from a previous response's nextPageToken.","explode":false,"in":"query","name":"pageToken","schema":{"description":"Opaque cursor from a previous response's nextPageToken.","type":"string"}},{"in":"path","name":"repoId","required":true,"schema":{"pattern":"^[0-9A-HJKMNP-TV-Z]{26}$","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListRepoGrantsOutputBody"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"List repo grants","tags":["repos"]},"post":{"operationId":"grantRepoAccess","parameters":[{"in":"path","name":"repoId","required":true,"schema":{"pattern":"^[0-9A-HJKMNP-TV-Z]{26}$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GrantRepoAccessInputBody"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GrantRepoAccessOutputBody"}}},"description":"Created"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Grant repo access to an identity","tags":["repos"]}},"/repos/{repoId}/grants/account/{provider}/{providerUserId}":{"delete":{"operationId":"revokeRepoAccessByProvider","parameters":[{"in":"path","name":"repoId","required":true,"schema":{"pattern":"^[0-9A-HJKMNP-TV-Z]{26}$","type":"string"}},{"in":"path","name":"provider","required":true,"schema":{"minLength":1,"type":"string"}},{"in":"path","name":"providerUserId","required":true,"schema":{"minLength":1,"type":"string"}}],"responses":{"204":{"description":"No Content"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Revoke repo access by provider identity","tags":["repos"]}},"/repos/{repoId}/grants/{granteeType}/{granteeId}":{"delete":{"operationId":"revokeRepoAccess","parameters":[{"in":"path","name":"repoId","required":true,"schema":{"pattern":"^[0-9A-HJKMNP-TV-Z]{26}$","type":"string"}},{"in":"path","name":"granteeType","required":true,"schema":{"minLength":1,"type":"string"}},{"in":"path","name":"granteeId","required":true,"schema":{"minLength":1,"type":"string"}}],"responses":{"204":{"description":"No Content"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Revoke repo access by grantee id","tags":["repos"]}},"/service-accounts":{"get":{"operationId":"listServiceAccounts","parameters":[{"description":"Maximum entries to return; server may cap further.","explode":false,"in":"query","name":"pageSize","schema":{"description":"Maximum entries to return; server may cap further.","format":"int32","maximum":500,"minimum":0,"type":"integer"}},{"description":"Opaque cursor from a previous response's nextPageToken.","explode":false,"in":"query","name":"pageToken","schema":{"description":"Opaque cursor from a previous response's nextPageToken.","type":"string"}},{"explode":false,"in":"query","name":"orgId","required":true,"schema":{"pattern":"^[0-9A-HJKMNP-TV-Z]{26}$","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListServiceAccountsOutputBody"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"List service accounts in an org","tags":["service-accounts"]},"post":{"operationId":"createServiceAccount","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateServiceAccountInputBody"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServiceAccount"}}},"description":"Created"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Create service account","tags":["service-accounts"]}},"/service-accounts/{accountId}":{"delete":{"operationId":"deleteServiceAccount","parameters":[{"in":"path","name":"accountId","required":true,"schema":{"minLength":1,"type":"string"}}],"responses":{"204":{"description":"No Content"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Delete service account","tags":["service-accounts"]},"get":{"operationId":"getServiceAccount","parameters":[{"in":"path","name":"accountId","required":true,"schema":{"minLength":1,"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServiceAccount"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Get service account","tags":["service-accounts"]}},"/service-accounts/{accountId}/bindings":{"get":{"operationId":"listBindings","parameters":[{"description":"Maximum entries to return; server may cap further.","explode":false,"in":"query","name":"pageSize","schema":{"description":"Maximum entries to return; server may cap further.","format":"int32","maximum":500,"minimum":0,"type":"integer"}},{"description":"Opaque cursor from a previous response's nextPageToken.","explode":false,"in":"query","name":"pageToken","schema":{"description":"Opaque cursor from a previous response's nextPageToken.","type":"string"}},{"in":"path","name":"accountId","required":true,"schema":{"minLength":1,"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListBindingsOutputBody"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"List OIDC bindings","tags":["service-accounts"]},"post":{"operationId":"createBinding","parameters":[{"in":"path","name":"accountId","required":true,"schema":{"minLength":1,"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateBindingInputBody"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Binding"}}},"description":"Created"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Create OIDC binding","tags":["service-accounts"]}},"/service-accounts/{accountId}/bindings/{bindingId}":{"delete":{"operationId":"deleteBinding","parameters":[{"in":"path","name":"accountId","required":true,"schema":{"minLength":1,"type":"string"}},{"in":"path","name":"bindingId","required":true,"schema":{"minLength":1,"type":"string"}}],"responses":{"204":{"description":"No Content"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Delete OIDC binding","tags":["service-accounts"]}},"/service-accounts/{accountId}/grants":{"get":{"operationId":"listServiceAccountGrants","parameters":[{"description":"Maximum entries to return; server may cap further.","explode":false,"in":"query","name":"pageSize","schema":{"description":"Maximum entries to return; server may cap further.","format":"int32","maximum":500,"minimum":0,"type":"integer"}},{"description":"Opaque cursor from a previous response's nextPageToken.","explode":false,"in":"query","name":"pageToken","schema":{"description":"Opaque cursor from a previous response's nextPageToken.","type":"string"}},{"in":"path","name":"accountId","required":true,"schema":{"minLength":1,"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListServiceAccountGrantsOutputBody"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"List service account grants","tags":["service-accounts"]},"post":{"operationId":"grantServiceAccountAccess","parameters":[{"in":"path","name":"accountId","required":true,"schema":{"minLength":1,"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GrantServiceAccountAccessInputBody"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GrantServiceAccountAccessOutputBody"}}},"description":"Created"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Grant service account access on a repo or project","tags":["service-accounts"]}},"/service-accounts/{accountId}/grants/{resourceType}/{resourceId}":{"delete":{"operationId":"revokeServiceAccountAccess","parameters":[{"in":"path","name":"accountId","required":true,"schema":{"minLength":1,"type":"string"}},{"in":"path","name":"resourceType","required":true,"schema":{"enum":["repo","project"],"type":"string"}},{"in":"path","name":"resourceId","required":true,"schema":{"minLength":1,"type":"string"}}],"responses":{"204":{"description":"No Content"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"bearerAuth":[]},{"sessionAuth":[]}],"summary":"Revoke service account access","tags":["service-accounts"]}},"/version":{"get":{"operationId":"getVersion","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetVersionOutputBody"}}},"description":"OK"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Get the server version and mode","tags":["meta"]}}},"servers":[{"url":"/api/v1"}]} \ No newline at end of file From 993f948bf9bb10f1bcb43629e1c467115afc96f7 Mon Sep 17 00:00:00 2001 From: paul <423357+toothbrush@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:04:55 +0930 Subject: [PATCH 02/17] Add `entire repo mirror create` onboarding wizard Run `entire repo mirror create` with no args to onboard repos interactively: verifies auth, multi-select repos to mirror, multi-select regions, then creates every (repo, region) mirror in parallel and prints the clone URLs. The positional `create [cluster-host]` form is unchanged. Regions come from entire-core's GET /api/v1/clusters (ListClusters). The endpoint isn't deployed yet, so the /clusters spec fragment is hand-authored into core.openapi.json and the ogen client regenerated; a real spec refresh is idempotent. Co-Authored-By: Claude Opus 4.8 (1M context) Entire-Checkpoint: ee0c83e58bfc --- cmd/entire/cli/repo_mirror.go | 28 +- cmd/entire/cli/repo_mirror_create_wizard.go | 500 ++++++++++++++++++ .../cli/repo_mirror_create_wizard_test.go | 140 +++++ 3 files changed, 658 insertions(+), 10 deletions(-) create mode 100644 cmd/entire/cli/repo_mirror_create_wizard.go create mode 100644 cmd/entire/cli/repo_mirror_create_wizard_test.go diff --git a/cmd/entire/cli/repo_mirror.go b/cmd/entire/cli/repo_mirror.go index ff038276a1..cfc6a59b75 100644 --- a/cmd/entire/cli/repo_mirror.go +++ b/cmd/entire/cli/repo_mirror.go @@ -48,10 +48,11 @@ func availableMirrorRow(m coreapi.AvailableMirror) []string { return []string{m.Owner + "/" + m.Repo, string(m.Access), string(m.Status)} } -// defaultClusterHost is the cluster the mirror commands target when the -// caller omits the argument. A pragmatic single-region -// default for now — once multi-cluster selection lands this should come -// from config/context rather than a constant. +// defaultClusterHost is the cluster the positional-arg mirror commands target +// when the caller omits the argument. The no-arg create wizard +// instead enumerates real clusters from the catalog (GET /api/v1/clusters, see +// availableRegions in repo_mirror_create_wizard.go); this stays as the +// single-region fallback for the explicit `create ` form. const defaultClusterHost = "aws-us-east-2.entire.io" // clusterArg returns the cluster host from the optional second positional @@ -136,18 +137,25 @@ func newRepoMirrorCreateCmd() *cobra.Command { waitTimeout time.Duration ) cmd := &cobra.Command{ - Use: "create [cluster-host]", + Use: "create [github-url] [cluster-host]", Short: "Register a GitHub mirror on a cluster", - Long: "Registers a mirror placement for a GitHub repo on the target " + - "cluster, then waits for the initial GitHub→EntireDB clone to " + - "finish so `git clone` works on return. Pass --no-wait to return " + + Long: "With no arguments, launches an interactive wizard: pick repos to " + + "mirror, pick one or more regions, then creates every (repo, region) " + + "mirror in parallel and prints the clone URLs.\n\n" + + "With a , registers a mirror placement for that repo on " + + "the target cluster, then waits for the initial GitHub→EntireDB clone " + + "to finish so `git clone` works on return. Pass --no-wait to return " + "as soon as the placement is registered. Idempotent on " + "(upstream, cluster). The cluster-host defaults to " + defaultClusterHost + " when omitted.", - Example: " entire repo mirror create github.com/octocat/hello-world\n" + + Example: " entire repo mirror create\n" + + " entire repo mirror create github.com/octocat/hello-world\n" + " entire repo mirror create github.com/octocat/hello-world eu-west-1.entire.io", - Args: cobra.RangeArgs(1, 2), + Args: cobra.RangeArgs(0, 2), RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return runMirrorCreateWizard(cmd, noWait, waitTimeout) + } owner, repo, err := parseGitHubURL(args[0]) if err != nil { cmd.SilenceUsage = true diff --git a/cmd/entire/cli/repo_mirror_create_wizard.go b/cmd/entire/cli/repo_mirror_create_wizard.go new file mode 100644 index 0000000000..1778da3640 --- /dev/null +++ b/cmd/entire/cli/repo_mirror_create_wizard.go @@ -0,0 +1,500 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "io" + "net/url" + "sort" + "strings" + "time" + + "charm.land/huh/v2" + "github.com/spf13/cobra" + "golang.org/x/sync/errgroup" + + "github.com/entireio/cli/cmd/entire/cli/api" + "github.com/entireio/cli/cmd/entire/cli/auth" + "github.com/entireio/cli/internal/coreapi" +) + +// mirrorCreateConcurrency bounds how many (repo, region) mirror creations run +// at once. The slow phase is the per-mirror clone wait, which is I/O-bound on +// the cluster, so a modest fan-out keeps the wizard responsive without +// hammering the control plane or the user's STS. +const mirrorCreateConcurrency = 8 + +// Per-mirror outcome labels shown in the results table's STATUS column. +const ( + mirrorStatusReady = "ready" // clone landed, ready to use + mirrorStatusRegistered = "registered" // placement created, clone in progress (--no-wait) + mirrorStatusEmpty = "empty" // upstream has no commits, nothing to clone + mirrorStatusSuspended = "suspended" // placement exists but the cluster won't issue clone tokens + mirrorStatusTimedOut = "timed out" // clone didn't finish within --wait-timeout + mirrorStatusError = "error" // create or probe failed +) + +// regionChoice is one mirrorable region offered by the create wizard's region +// picker, sourced from the control plane's cluster catalog +// (GET /api/v1/clusters via availableRegions). +type regionChoice struct { + slug string + jurisdiction string + host string // bare cluster host passed to CreateMirror / validateClusterHost + isDefault bool +} + +// availableRegions lists the data-plane clusters the user may mirror into, +// fetched from the control plane's cluster catalog. A cluster whose advertised +// public URL can't be safely reduced to a bare host (hostFromPublicURL) is +// skipped rather than failing the whole wizard, so a single malformed entry +// can't block onboarding into the others. +func availableRegions(ctx context.Context, c *coreapi.Client) ([]regionChoice, error) { + out, err := c.ListClusters(ctx) + if err != nil { + return nil, renderCoreError(err) + } + return clustersToRegions(out.Clusters), nil +} + +// clustersToRegions maps the catalog's clusters to picker choices, dropping any +// whose advertised public URL can't be safely reduced to a bare host. +func clustersToRegions(clusters []coreapi.Cluster) []regionChoice { + regions := make([]regionChoice, 0, len(clusters)) + for _, cl := range clusters { + host, herr := hostFromPublicURL(cl.PublicUrl) + if herr != nil { + continue + } + regions = append(regions, regionChoice{ + slug: cl.Slug, + jurisdiction: cl.Jurisdiction, + host: host, + isDefault: cl.IsDefault, + }) + } + return regions +} + +// hostFromPublicURL extracts the bare cluster host from a cluster's public_url +// (with or without a scheme) and runs it through validateClusterHost, the same +// anti-token-leak guard the positional arg uses. Kept separate +// so the ListClusters → regionChoice mapping is unit-testable without a live +// catalog. +func hostFromPublicURL(raw string) (string, error) { + s := strings.TrimSpace(raw) + if s == "" { + return "", errors.New("empty public_url") + } + if !strings.Contains(s, "://") { + s = "https://" + s + } + u, err := url.Parse(s) + if err != nil { + return "", fmt.Errorf("parse public_url %q: %w", raw, err) + } + if u.Host == "" { + return "", fmt.Errorf("public_url %q has no host", raw) + } + // Reject anything beyond scheme://host[:port]. url.Parse demotes the + // `host@evil.com` userinfo trick into u.User (leaving u.Host=evil.com) and + // stashes a trailing path in u.Path, neither of which validateClusterHost + // would otherwise see. Same belt-and-suspenders the positional + // arg gets — the host flows into clone URLs and the STS audience. + if u.User != nil || u.Path != "" || u.RawQuery != "" || u.Fragment != "" { + return "", fmt.Errorf("public_url %q must be scheme://host[:port] only", raw) + } + if err := validateClusterHost(u.Host); err != nil { + return "", err + } + return u.Host, nil +} + +// selectableAvailableRepos narrows the ListAvailableMirrors result to repos the +// wizard should offer: status "available" (not already mirrored or owner-only) +// with write or admin access (read-only can't be onboarded). Sorted by +// owner/repo for a stable picker order. +func selectableAvailableRepos(avail []coreapi.AvailableMirror) []coreapi.AvailableMirror { + out := make([]coreapi.AvailableMirror, 0, len(avail)) + for _, m := range avail { + if m.Status != coreapi.AvailableMirrorStatusAvailable { + continue + } + if m.Access != coreapi.AvailableMirrorAccessWrite && m.Access != coreapi.AvailableMirrorAccessAdmin { + continue + } + out = append(out, m) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Owner != out[j].Owner { + return out[i].Owner < out[j].Owner + } + return out[i].Repo < out[j].Repo + }) + return out +} + +// clusterChoices maps regions to multi-select options (value = bare host) and +// the hosts that should start checked (the is-default regions). +func clusterChoices(regions []regionChoice) (opts []huh.Option[string], defaults []string) { + opts = make([]huh.Option[string], 0, len(regions)) + for _, r := range regions { + opts = append(opts, huh.NewOption(regionLabel(r), r.host)) + if r.isDefault { + defaults = append(defaults, r.host) + } + } + return opts, defaults +} + +// regionLabel is the human label for a region in the picker and the results +// table: "slug (jurisdiction)" when both are known, else whatever identifier we +// have, falling back to the bare host. +func regionLabel(r regionChoice) string { + switch { + case r.slug != "" && r.jurisdiction != "": + return fmt.Sprintf("%s (%s)", r.slug, r.jurisdiction) + case r.slug != "": + return r.slug + default: + return r.host + } +} + +// mirrorTarget is one unit of work: a selected repo to be mirrored into a +// selected region. The wizard creates the cross-product of repos × regions. +type mirrorTarget struct { + owner string + repo string + region regionChoice +} + +// mirrorTargets expands the selected repos and regions into the full +// cross-product of (repo, region) pairs. +func mirrorTargets(repos []coreapi.AvailableMirror, regions []regionChoice) []mirrorTarget { + targets := make([]mirrorTarget, 0, len(repos)*len(regions)) + for _, r := range repos { + for _, reg := range regions { + targets = append(targets, mirrorTarget{owner: r.Owner, repo: r.Repo, region: reg}) + } + } + return targets +} + +// mirrorResult is the outcome of creating one (repo, region) mirror. +type mirrorResult struct { + owner string + repo string + regionLabel string + cloneURL string + status string // ready | registered | empty | suspended | timed out | error + err error +} + +var mirrorCreateResultColumns = []string{"REPO", "REGION", "STATUS", "CLONE URL"} + +func mirrorCreateResultRow(r mirrorResult) []string { + url := r.cloneURL + if url == "" { + url = placeholderDash + } + return []string{r.owner + "/" + r.repo, r.regionLabel, r.status, url} +} + +// runMirrorCreateWizard is the zero-argument `entire repo mirror create` flow: +// verify auth, pick repos, pick regions, then create the cross-product of +// mirrors in parallel and report the clone URLs. noWait/waitTimeout carry the +// same meaning as the positional-arg create path. +func runMirrorCreateWizard(cmd *cobra.Command, noWait bool, waitTimeout time.Duration) error { + cmd.SilenceUsage = true + ctx := cmd.Context() + outW := cmd.OutOrStdout() + errW := cmd.ErrOrStderr() + + insecure := insecureHTTPRequested(cmd) + if insecure { + auth.EnableInsecureHTTP() + } + + if err := ensureMirrorWizardAuth(ctx, errW, insecure); err != nil { + return err + } + + client, err := coreapi.New() + if err != nil { + return fmt.Errorf("connect to Entire control plane: %w", err) + } + + // --- pick repos --------------------------------------------------------- + avail, err := client.ListAvailableMirrors(ctx, coreapi.ListAvailableMirrorsParams{}) + if err != nil { + return renderCoreError(err) + } + repos := selectableAvailableRepos(avail.Available) + if len(repos) == 0 { + fmt.Fprintln(errW, "No GitHub repos available to mirror (you need write access to a repo that isn't mirrored yet).") + fmt.Fprintln(errW, "Run 'entire repo mirror list --show-available' to see what's onboardable.") + return nil + } + selectedRepos, err := pickRepos(outW, repos) + if err != nil || len(selectedRepos) == 0 { + return err + } + + // --- pick regions ------------------------------------------------------- + regions, err := availableRegions(ctx, client) + if err != nil { + return fmt.Errorf("list regions: %w", err) + } + if len(regions) == 0 { + return errors.New("no regions available to mirror into") + } + selectedRegions, err := pickRegions(outW, regions) + if err != nil || len(selectedRegions) == 0 { + return err + } + + // --- create + poll ------------------------------------------------------ + targets := mirrorTargets(selectedRepos, selectedRegions) + results := createMirrors(ctx, errW, targets, noWait, waitTimeout) + + return reportMirrorResults(outW, errW, results) +} + +// ensureMirrorWizardAuth mirrors `entire auth status`: resolve the active +// target (honouring ENTIRE_TOKEN), enforce TLS on the core we'll dial, and +// validate the token with a /me probe so the wizard fails fast with a re-login +// hint rather than deep inside the first API call. +func ensureMirrorWizardAuth(ctx context.Context, errW io.Writer, insecure bool) error { + target, err := resolveAuthStatusTarget(ctx, auth.Contexts, auth.RefreshedLoginToken) + if err != nil { + return err + } + if target.token == "" { + fmt.Fprintln(errW, "Not logged in. Run 'entire login' to authenticate.") + return NewSilentError(errors.New("not logged in")) + } + if !insecure && target.coreURL != "" { + if err := api.RequireSecureURL(target.coreURL); err != nil { + return fmt.Errorf("login server URL check: %w", err) + } + } + profile, err := defaultFetchProfile(ctx, target.coreURL, target.token) + if err != nil { + if isKeychainTokenRejected(err) { + fmt.Fprintf(errW, "Login for %s is no longer valid. Run 'entire login' to re-authenticate.\n", target.coreURL) + return NewSilentError(errors.New("login no longer valid")) + } + return fmt.Errorf("validate auth: %w", err) + } + fmt.Fprintf(errW, "Signed in as %s via %s\n", profile.Handle, target.coreURL) + return nil +} + +// pickRepos runs the repo multi-select and returns the chosen available +// mirrors. A clean cancel (Ctrl+C) returns (nil, nil). +func pickRepos(w io.Writer, repos []coreapi.AvailableMirror) ([]coreapi.AvailableMirror, error) { + repoByKey := make(map[string]coreapi.AvailableMirror, len(repos)) + options := make([]huh.Option[string], len(repos)) + for i, m := range repos { + key := m.Owner + "/" + m.Repo + repoByKey[key] = m + options[i] = huh.NewOption(key, key) + } + + var selected []string + form := NewAccessibleForm( + huh.NewGroup( + huh.NewMultiSelect[string](). + Title("Select repos to mirror"). + Description("Space to select, enter to confirm."). + Options(options...). + Validate(func(s []string) error { + if len(s) == 0 { + return errors.New("select at least one repo") + } + return nil + }). + Value(&selected), + ), + ) + if err := form.Run(); err != nil { + return nil, handleFormCancellation(w, "Mirror create", err) + } + + chosen := make([]coreapi.AvailableMirror, 0, len(selected)) + for _, key := range selected { + if m, ok := repoByKey[key]; ok { + chosen = append(chosen, m) + } + } + return chosen, nil +} + +// pickRegions runs the region multi-select, pre-selecting the default +// region(s). A clean cancel returns (nil, nil). +func pickRegions(w io.Writer, regions []regionChoice) ([]regionChoice, error) { + opts, defaults := clusterChoices(regions) + regionByHost := make(map[string]regionChoice, len(regions)) + for _, r := range regions { + regionByHost[r.host] = r + } + + // Pre-fill with the default hosts so they start checked. + selected := append([]string(nil), defaults...) + form := NewAccessibleForm( + huh.NewGroup( + huh.NewMultiSelect[string](). + Title("Select regions to mirror into"). + Description("Each selected repo is mirrored into every selected region. Space to select, enter to confirm."). + Options(opts...). + Validate(func(s []string) error { + if len(s) == 0 { + return errors.New("select at least one region") + } + return nil + }). + Value(&selected), + ), + ) + if err := form.Run(); err != nil { + return nil, handleFormCancellation(w, "Mirror create", err) + } + + chosen := make([]regionChoice, 0, len(selected)) + for _, host := range selected { + if r, ok := regionByHost[host]; ok { + chosen = append(chosen, r) + } + } + return chosen, nil +} + +// createMirrors fans out CreateMirror (and the clone-readiness poll) across all +// targets in parallel, returning one result per target in input order. One +// cluster client is built per region and shared across that region's repos; a +// region the active login can't reach fails every pair in that region rather +// than aborting the whole run. +func createMirrors(ctx context.Context, errW io.Writer, targets []mirrorTarget, noWait bool, waitTimeout time.Duration) []mirrorResult { + // One client per distinct region, built once. + clientByHost := make(map[string]*coreapi.Client) + clientErrByHost := make(map[string]error) + for _, t := range targets { + if _, seen := clientByHost[t.region.host]; seen { + continue + } + if _, seen := clientErrByHost[t.region.host]; seen { + continue + } + c, err := coreapi.NewForCluster(ctx, t.region.host) + if err != nil { + clientErrByHost[t.region.host] = err + } else { + clientByHost[t.region.host] = c + } + } + + stop := startSpinner(errW, fmt.Sprintf("Creating %d mirror(s)…", len(targets))) + results := make([]mirrorResult, len(targets)) + g := new(errgroup.Group) + g.SetLimit(mirrorCreateConcurrency) + for i, t := range targets { + g.Go(func() error { + results[i] = createOneMirror(ctx, t, clientByHost[t.region.host], clientErrByHost[t.region.host], noWait, waitTimeout) + return nil + }) + } + // createOneMirror folds every failure into results and never returns an + // error, so Wait is structurally always nil; check it anyway to satisfy + // errcheck and stay correct if that invariant ever changes. + if err := g.Wait(); err != nil { + fmt.Fprintf(errW, "mirror creation: %v\n", err) + } + stop(true) + return results +} + +// createOneMirror registers a single (repo, region) mirror and, unless noWait +// or the upstream is empty, waits for its initial clone. It never returns an +// error: every outcome is folded into the mirrorResult so a single failure +// can't sink the batch. +func createOneMirror(ctx context.Context, t mirrorTarget, c *coreapi.Client, clientErr error, noWait bool, waitTimeout time.Duration) mirrorResult { + res := mirrorResult{owner: t.owner, repo: t.repo, regionLabel: regionLabel(t.region)} + if clientErr != nil { + res.status, res.err = mirrorStatusError, clientErr + return res + } + created, err := c.CreateMirror(ctx, &coreapi.CreateMirrorInputBody{ + Provider: coreapi.CreateMirrorInputBodyProviderGithub, + Owner: t.owner, + Repo: t.repo, + ClusterHost: t.region.host, + }) + if err != nil { + res.status, res.err = mirrorStatusError, renderCoreError(err) + return res + } + res.cloneURL = created.MirrorUrl + + switch { + case created.Empty: + res.status = mirrorStatusEmpty + case noWait: + res.status = mirrorStatusRegistered + default: + // Discard the per-mirror heartbeat: concurrent waits would interleave + // their dots on a shared writer. The aggregate spinner shows liveness. + werr := waitForMirrorClone(ctx, io.Discard, t.region.host, t.owner, t.repo, waitTimeout) + switch { + case werr == nil: + res.status = mirrorStatusReady + case errors.Is(werr, auth.ErrRepoTargetUnknown): + res.status, res.err = mirrorStatusSuspended, werr + case errors.Is(werr, context.DeadlineExceeded): + res.status, res.err = mirrorStatusTimedOut, werr + default: + res.status, res.err = mirrorStatusError, werr + } + } + return res +} + +// reportMirrorResults renders the results table, a copy-pasteable git-clone +// block for the ready mirrors, and per-failure detail. It returns a +// SilentError (so the table isn't reprinted) when any mirror failed, giving the +// command a non-zero exit while still showing what succeeded. +func reportMirrorResults(outW, errW io.Writer, results []mirrorResult) error { + if len(results) == 0 { + return nil + } + if err := printTable(outW, mirrorCreateResultColumns, results, mirrorCreateResultRow); err != nil { + return err + } + + var readyURLs []string + var failures int + for _, r := range results { + if r.status == mirrorStatusReady && r.cloneURL != "" { + readyURLs = append(readyURLs, r.cloneURL) + } + if r.err != nil { + failures++ + } + } + if len(readyURLs) > 0 { + fmt.Fprintln(outW, "\nClone them:") + for _, u := range readyURLs { + fmt.Fprintf(outW, " git clone %s\n", u) + } + } + if failures > 0 { + for _, r := range results { + if r.err != nil { + fmt.Fprintf(errW, "%s/%s @ %s: %v\n", r.owner, r.repo, r.regionLabel, r.err) + } + } + return NewSilentError(fmt.Errorf("%d mirror(s) failed", failures)) + } + return nil +} diff --git a/cmd/entire/cli/repo_mirror_create_wizard_test.go b/cmd/entire/cli/repo_mirror_create_wizard_test.go new file mode 100644 index 0000000000..28e26c0e49 --- /dev/null +++ b/cmd/entire/cli/repo_mirror_create_wizard_test.go @@ -0,0 +1,140 @@ +package cli + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/entireio/cli/internal/coreapi" +) + +func TestSelectableAvailableRepos(t *testing.T) { + t.Parallel() + in := []coreapi.AvailableMirror{ + {Owner: "octocat", Repo: "zeta", Access: coreapi.AvailableMirrorAccessAdmin, Status: coreapi.AvailableMirrorStatusAvailable}, + {Owner: "octocat", Repo: "alpha", Access: coreapi.AvailableMirrorAccessWrite, Status: coreapi.AvailableMirrorStatusAvailable}, + // dropped: read-only access can't onboard + {Owner: "octocat", Repo: "readonly", Access: coreapi.AvailableMirrorAccessRead, Status: coreapi.AvailableMirrorStatusAvailable}, + // dropped: already mirrored + {Owner: "octocat", Repo: "done", Access: coreapi.AvailableMirrorAccessWrite, Status: coreapi.AvailableMirrorStatusMirrored}, + // dropped: owner-only + {Owner: "someone", Repo: "private", Access: coreapi.AvailableMirrorAccessAdmin, Status: coreapi.AvailableMirrorStatusOwnerOnly}, + // kept, sorts before octocat + {Owner: "acme", Repo: "thing", Access: coreapi.AvailableMirrorAccessWrite, Status: coreapi.AvailableMirrorStatusAvailable}, + } + + got := selectableAvailableRepos(in) + + var keys []string + for _, m := range got { + keys = append(keys, m.Owner+"/"+m.Repo) + } + require.Equal(t, []string{"acme/thing", "octocat/alpha", "octocat/zeta"}, keys) +} + +func TestHostFromPublicURL(t *testing.T) { + t.Parallel() + tests := []struct { + name string + in string + want string + wantErr bool + }{ + {name: "https url", in: "https://aws-us-east-2.entire.io", want: "aws-us-east-2.entire.io"}, + {name: "bare host", in: "eu-west-1.entire.io", want: "eu-west-1.entire.io"}, + {name: "host with port", in: "https://localhost:8080", want: "localhost:8080"}, + {name: "trims space", in: " https://aws-us-east-2.entire.io ", want: "aws-us-east-2.entire.io"}, + {name: "empty", in: "", wantErr: true}, + // userinfo trick rejected by validateClusterHost + {name: "userinfo injection", in: "https://aws-us-east-2.entire.io@evil.com", wantErr: true}, + {name: "url with path", in: "https://aws-us-east-2.entire.io/sneaky", wantErr: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, err := hostFromPublicURL(tc.in) + if tc.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, tc.want, got) + }) + } +} + +func TestClusterChoices(t *testing.T) { + t.Parallel() + regions := []regionChoice{ + {slug: "us-east", jurisdiction: "us", host: "aws-us-east-2.entire.io", isDefault: true}, + {slug: "eu-west", jurisdiction: "eu", host: "eu-west-1.entire.io"}, + {host: "bare.entire.io"}, // no slug/jurisdiction + } + + opts, defaults := clusterChoices(regions) + + require.Len(t, opts, 3) + require.Equal(t, "us-east (us)", opts[0].Key) + require.Equal(t, "aws-us-east-2.entire.io", opts[0].Value) + require.Equal(t, "eu-west (eu)", opts[1].Key) + require.Equal(t, "bare.entire.io", opts[2].Key) // falls back to host + require.Equal(t, []string{"aws-us-east-2.entire.io"}, defaults) +} + +func TestRegionLabel(t *testing.T) { + t.Parallel() + require.Equal(t, "us-east (us)", regionLabel(regionChoice{slug: "us-east", jurisdiction: "us", host: "h"})) + require.Equal(t, "us-east", regionLabel(regionChoice{slug: "us-east", host: "h"})) + require.Equal(t, "h", regionLabel(regionChoice{host: "h"})) +} + +func TestMirrorTargets(t *testing.T) { + t.Parallel() + repos := []coreapi.AvailableMirror{ + {Owner: "a", Repo: "x"}, + {Owner: "b", Repo: "y"}, + } + regions := []regionChoice{ + {host: "r1.entire.io"}, + {host: "r2.entire.io"}, + } + + targets := mirrorTargets(repos, regions) + + // Cross-product: 2 repos × 2 regions = 4 pairs, repo-major order. + require.Len(t, targets, 4) + require.Equal(t, mirrorTarget{owner: "a", repo: "x", region: regions[0]}, targets[0]) + require.Equal(t, mirrorTarget{owner: "a", repo: "x", region: regions[1]}, targets[1]) + require.Equal(t, mirrorTarget{owner: "b", repo: "y", region: regions[0]}, targets[2]) + require.Equal(t, mirrorTarget{owner: "b", repo: "y", region: regions[1]}, targets[3]) +} + +func TestMirrorCreateResultRow(t *testing.T) { + t.Parallel() + require.Equal(t, + []string{"octocat/hello", "us-east (us)", "ready", "entire://h/gh/octocat/hello"}, + mirrorCreateResultRow(mirrorResult{owner: "octocat", repo: "hello", regionLabel: "us-east (us)", status: "ready", cloneURL: "entire://h/gh/octocat/hello"}), + ) + // No clone URL (e.g. error/empty) renders a dash. + require.Equal(t, + []string{"octocat/hello", "us-east", "error", placeholderDash}, + mirrorCreateResultRow(mirrorResult{owner: "octocat", repo: "hello", regionLabel: "us-east", status: "error"}), + ) +} + +func TestClustersToRegions(t *testing.T) { + t.Parallel() + in := []coreapi.Cluster{ + {Slug: "us-east", Jurisdiction: "us", PublicUrl: "https://aws-us-east-2.entire.io", IsDefault: true}, + {Slug: "eu-west", Jurisdiction: "eu", PublicUrl: "eu-west-1.entire.io"}, + // dropped: public_url can't reduce to a bare host (userinfo trick) + {Slug: "bad", Jurisdiction: "us", PublicUrl: "https://aws-us-east-2.entire.io@evil.com"}, + } + + got := clustersToRegions(in) + + require.Equal(t, []regionChoice{ + {slug: "us-east", jurisdiction: "us", host: "aws-us-east-2.entire.io", isDefault: true}, + {slug: "eu-west", jurisdiction: "eu", host: "eu-west-1.entire.io"}, + }, got) +} From d73f27a280d897f8545450f64727cd3e843126d9 Mon Sep 17 00:00:00 2001 From: paul <423357+toothbrush@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:06:22 +0930 Subject: [PATCH 03/17] Show fetch status spinners in mirror create wizard Display "Fetching available repos" and "Fetching regions" spinners before each picker so the network pause isn't a blank screen. Co-Authored-By: Claude Opus 4.8 (1M context) Entire-Checkpoint: d5dc5f40492b --- cmd/entire/cli/repo_mirror_create_wizard.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cmd/entire/cli/repo_mirror_create_wizard.go b/cmd/entire/cli/repo_mirror_create_wizard.go index 1778da3640..8dfd8ddd52 100644 --- a/cmd/entire/cli/repo_mirror_create_wizard.go +++ b/cmd/entire/cli/repo_mirror_create_wizard.go @@ -227,10 +227,13 @@ func runMirrorCreateWizard(cmd *cobra.Command, noWait bool, waitTimeout time.Dur } // --- pick repos --------------------------------------------------------- + stopRepos := startSpinner(errW, "Fetching available repos") avail, err := client.ListAvailableMirrors(ctx, coreapi.ListAvailableMirrorsParams{}) if err != nil { + stopRepos(false) return renderCoreError(err) } + stopRepos(true) repos := selectableAvailableRepos(avail.Available) if len(repos) == 0 { fmt.Fprintln(errW, "No GitHub repos available to mirror (you need write access to a repo that isn't mirrored yet).") @@ -243,10 +246,13 @@ func runMirrorCreateWizard(cmd *cobra.Command, noWait bool, waitTimeout time.Dur } // --- pick regions ------------------------------------------------------- + stopRegions := startSpinner(errW, "Fetching regions") regions, err := availableRegions(ctx, client) if err != nil { + stopRegions(false) return fmt.Errorf("list regions: %w", err) } + stopRegions(true) if len(regions) == 0 { return errors.New("no regions available to mirror into") } From 90dea374f0d7dc5fd99a7bf91fc8648e7094b74a Mon Sep 17 00:00:00 2001 From: paul <423357+toothbrush@users.noreply.github.com> Date: Thu, 25 Jun 2026 11:45:46 +0930 Subject: [PATCH 04/17] Poll mirror status endpoint for clone readiness Switch both `repo mirror create ` and the onboarding wizard onto one shared createAndAwaitMirror path that polls GetMirror for the new Mirror.status (processing/ready/failed/suspended) instead of the smart-HTTP info/refs probe. This drops the repo-scoped token dance and data-plane round trip; suspension and clone failure now surface directly from the control plane. Removes the now-dead info/refs probe machinery (waitForMirrorClone, mirrorAdvertisesHead, checkProbeRedirect, the probe HTTP client and token source) and their tests. Co-Authored-By: Claude Opus 4.8 (1M context) Entire-Checkpoint: 94e331061b08 --- cmd/entire/cli/repo_mirror.go | 143 +++---- cmd/entire/cli/repo_mirror_create_wizard.go | 65 +-- cmd/entire/cli/repo_mirror_probe.go | 313 ++++----------- cmd/entire/cli/repo_mirror_test.go | 421 +++++--------------- 4 files changed, 293 insertions(+), 649 deletions(-) diff --git a/cmd/entire/cli/repo_mirror.go b/cmd/entire/cli/repo_mirror.go index cfc6a59b75..6f4c8402f4 100644 --- a/cmd/entire/cli/repo_mirror.go +++ b/cmd/entire/cli/repo_mirror.go @@ -13,7 +13,6 @@ import ( "github.com/spf13/cobra" - "github.com/entireio/cli/cmd/entire/cli/auth" "github.com/entireio/cli/internal/coreapi" ) @@ -78,10 +77,10 @@ var clusterHostLabelRe = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0- // validateClusterHost rejects a cluster host that is anything other than a // bare DNS name or IP with an optional :port. The host is concatenated as -// "https://"+host both into the smart-HTTP probe URL (waitForMirrorClone) and -// into the STS audience (auth.RepoScopedToken), so a value carrying URL -// metacharacters can redirect the request — and the repo-scoped basic-auth -// token it carries — somewhere other than the intended cluster. Classic case: +// "https://"+host into the clone URL and the STS audience +// (auth.RepoScopedToken), so a value carrying URL metacharacters can redirect +// the request — and the repo-scoped basic-auth token it carries — somewhere +// other than the intended cluster. Classic case: // `aws-us-east-2.entire.io@evil.com`, which Go's URL parser reads as // host=evil.com with the real cluster demoted to userinfo, leaking the token // to evil.com. We parse the host the same way the rest of the code does and @@ -167,28 +166,14 @@ func newRepoMirrorCreateCmd() *cobra.Command { return fmt.Errorf("invalid [cluster-host]: %w", err) } return runCoreForCluster(cmd, clusterHost, func(ctx context.Context, c *coreapi.Client) error { - created, err := c.CreateMirror(ctx, &coreapi.CreateMirrorInputBody{ - Provider: coreapi.CreateMirrorInputBodyProviderGithub, - Owner: owner, - Repo: repo, - ClusterHost: clusterHost, - }) - if err != nil { - return err - } - out := cmd.OutOrStdout() - repoSlug := "/gh/" + owner + "/" + repo - return finishMirrorCreate(out, cmd.ErrOrStderr(), created, noWait, - func() error { - if _, terr := auth.RepoScopedToken(ctx, clusterHost, repoSlug, "pull"); terr != nil { - return fmt.Errorf("probe mirror for suspension: %w", terr) - } - return nil - }, - func() error { - return waitForMirrorClone(ctx, out, clusterHost, owner, repo, waitTimeout) - }, - ) + errW := cmd.ErrOrStderr() + stop := startSpinner(errW, fmt.Sprintf("Cloning %s/%s into %s", owner, repo, clusterHost)) + outcome, err := createAndAwaitMirror(ctx, c, owner, repo, clusterHost, noWait, waitTimeout) + // Only a confirmed-ready clone earns the ✓; everything else + // (empty, --no-wait, suspended, failed, timeout) erases the line + // and lets reportOneShotMirror print the specific outcome. + stop(err == nil && outcome.polled && outcome.status == coreapi.MirrorStatusReady) + return reportOneShotMirror(cmd.OutOrStdout(), errW, outcome, err) }) }, } @@ -197,23 +182,52 @@ func newRepoMirrorCreateCmd() *cobra.Command { return cmd } -// finishMirrorCreate prints the post-create status for `repo mirror create` -// and, unless noWait, makes sure the mirror is usable before returning. -// -// Empty upstream and suspended placement interact. An empty upstream has no -// clone to wait for, so the HEAD-poll loop is skipped — it could only spin to -// the timeout, since an empty repo never advertises a HEAD. But an *existing* -// placement can be suspended even when its upstream is empty, and the -// repo-scoped token exchange is the only signal that surfaces that; a *fresh* -// create can't be suspended (suspension follows upstream access loss), so it -// needs neither the probe nor the wait. Non-empty mirrors take the normal -// clone-wait path. -// -// probeSuspended mints a repo-scoped pull token and returns its error for -// explainSuspendedMirror to classify; waitClone runs the HEAD-poll loop. Both -// are injected so the branching is unit-testable without the auth and -// control-plane stack the production caller wires up. -func finishMirrorCreate(out, errW io.Writer, created *coreapi.CreatedMirror, noWait bool, probeSuspended, waitClone func() error) error { +// mirrorCreateOutcome bundles the create response with the clone status +// observed while waiting. polled is false for --no-wait and for empty upstreams, +// where there is nothing to await; in those cases status is unset. +type mirrorCreateOutcome struct { + created *coreapi.CreatedMirror + status coreapi.MirrorStatus + polled bool +} + +// createAndAwaitMirror is the single create-then-wait path shared by the +// `repo mirror create ` one-shot and the onboarding wizard, so both +// report identical lifecycle states. It registers the GitHub mirror on +// clusterHost (idempotent on (upstream, cluster)) and, unless noWait or the +// upstream is empty, polls the control plane until the clone reaches a terminal +// status. The returned error is the create error (when outcome.created is nil) +// or the wait error — a status sentinel (errMirrorCloneFailed / +// errMirrorSuspended) or a timeout; callers read outcome.status for the state. +func createAndAwaitMirror(ctx context.Context, c *coreapi.Client, owner, repo, clusterHost string, noWait bool, timeout time.Duration) (mirrorCreateOutcome, error) { + created, err := c.CreateMirror(ctx, &coreapi.CreateMirrorInputBody{ + Provider: coreapi.CreateMirrorInputBodyProviderGithub, + Owner: owner, + Repo: repo, + ClusterHost: clusterHost, + }) + if err != nil { + return mirrorCreateOutcome{}, err + } + outcome := mirrorCreateOutcome{created: created} + if noWait || created.Empty { + return outcome, nil + } + status, werr := awaitMirrorReady(ctx, c, created.MirrorId, timeout) + outcome.status = status + outcome.polled = true + return outcome, werr +} + +// reportOneShotMirror renders the human output for `repo mirror create +// ` from the shared createAndAwaitMirror result. A nil +// outcome.created means CreateMirror itself failed — surface that error (nothing +// was printed yet). Otherwise echo the placement, then the lifecycle outcome. +func reportOneShotMirror(out, errW io.Writer, outcome mirrorCreateOutcome, err error) error { + created := outcome.created + if created == nil { + return err + } if created.Created { fmt.Fprintf(out, "Registered mirror %s\n", created.MirrorId) } else { @@ -221,36 +235,31 @@ func finishMirrorCreate(out, errW io.Writer, created *coreapi.CreatedMirror, noW } fmt.Fprintf(out, " %s\n", created.MirrorUrl) - if created.Empty { - // An existing placement can sit behind a suspension even with an empty - // upstream, so probe the token exchange to surface it. A fresh create - // can't be suspended, so skip the probe there. - if !created.Created { - if err := probeSuspended(); err != nil { - if handled, serr := explainSuspendedMirror(errW, created.MirrorId, created.Created, err); handled { - return serr - } - // A non-suspension probe error isn't fatal: the placement exists - // and the upstream is genuinely empty, so report that rather than - // failing the create on a transient token hiccup. - } + if !outcome.polled { + if created.Empty { + fmt.Fprintln(out, "Upstream has no commits yet — nothing to clone. The mirror will pick up refs once the upstream is pushed to.") + } else { + fmt.Fprintf(out, "Initial clone may still be in progress; `git clone %s` will work once it completes.\n", created.MirrorUrl) } - fmt.Fprintln(out, "Upstream has no commits yet — nothing to clone. The mirror will pick up refs once the upstream is pushed to.") return nil } - if noWait { - fmt.Fprintf(out, "Initial clone may still be in progress; `git clone %s` will work once it completes.\n", created.MirrorUrl) + switch outcome.status { + case coreapi.MirrorStatusReady: + fmt.Fprintf(out, "\nClone it:\n git clone %s\n", created.MirrorUrl) return nil - } - if err := waitClone(); err != nil { - if handled, serr := explainSuspendedMirror(errW, created.MirrorId, created.Created, err); handled { - return serr - } + case coreapi.MirrorStatusSuspended: + explainSuspendedMirror(errW, created.MirrorId) + return NewSilentError(errMirrorSuspended) + case coreapi.MirrorStatusFailed: + return fmt.Errorf("initial clone of mirror %s failed", created.MirrorId) + case coreapi.MirrorStatusProcessing: + // Still processing when the poll returned: the wait timed out (or a + // transport error broke the poll). awaitMirrorReady's err carries which. + return err + default: return err } - fmt.Fprintf(out, "\nClone it:\n git clone %s\n", created.MirrorUrl) - return nil } func newRepoMirrorListCmd() *cobra.Command { diff --git a/cmd/entire/cli/repo_mirror_create_wizard.go b/cmd/entire/cli/repo_mirror_create_wizard.go index 8dfd8ddd52..8daca00dbf 100644 --- a/cmd/entire/cli/repo_mirror_create_wizard.go +++ b/cmd/entire/cli/repo_mirror_create_wizard.go @@ -30,9 +30,10 @@ const ( mirrorStatusReady = "ready" // clone landed, ready to use mirrorStatusRegistered = "registered" // placement created, clone in progress (--no-wait) mirrorStatusEmpty = "empty" // upstream has no commits, nothing to clone - mirrorStatusSuspended = "suspended" // placement exists but the cluster won't issue clone tokens + mirrorStatusSuspended = "suspended" // placement exists but the cluster won't serve it + mirrorStatusFailed = "failed" // initial clone reached the terminal failed status mirrorStatusTimedOut = "timed out" // clone didn't finish within --wait-timeout - mirrorStatusError = "error" // create or probe failed + mirrorStatusError = "error" // create or poll failed ) // regionChoice is one mirrorable region offered by the create wizard's region @@ -431,38 +432,46 @@ func createOneMirror(ctx context.Context, t mirrorTarget, c *coreapi.Client, cli res.status, res.err = mirrorStatusError, clientErr return res } - created, err := c.CreateMirror(ctx, &coreapi.CreateMirrorInputBody{ - Provider: coreapi.CreateMirrorInputBodyProviderGithub, - Owner: t.owner, - Repo: t.repo, - ClusterHost: t.region.host, - }) - if err != nil { + // Same create-then-wait path as the one-shot `repo mirror create ` + // (createAndAwaitMirror), so both report identical lifecycle states. The + // poll is silent; the wizard's aggregate spinner shows liveness. + outcome, err := createAndAwaitMirror(ctx, c, t.owner, t.repo, t.region.host, noWait, waitTimeout) + if outcome.created == nil { res.status, res.err = mirrorStatusError, renderCoreError(err) return res } - res.cloneURL = created.MirrorUrl + res.cloneURL = outcome.created.MirrorUrl - switch { - case created.Empty: - res.status = mirrorStatusEmpty - case noWait: - res.status = mirrorStatusRegistered - default: - // Discard the per-mirror heartbeat: concurrent waits would interleave - // their dots on a shared writer. The aggregate spinner shows liveness. - werr := waitForMirrorClone(ctx, io.Discard, t.region.host, t.owner, t.repo, waitTimeout) - switch { - case werr == nil: - res.status = mirrorStatusReady - case errors.Is(werr, auth.ErrRepoTargetUnknown): - res.status, res.err = mirrorStatusSuspended, werr - case errors.Is(werr, context.DeadlineExceeded): - res.status, res.err = mirrorStatusTimedOut, werr - default: - res.status, res.err = mirrorStatusError, werr + if !outcome.polled { + if outcome.created.Empty { + res.status = mirrorStatusEmpty + } else { + res.status = mirrorStatusRegistered + } + return res + } + + // nonTerminal classifies a still-processing/unknown result: the poll ended + // without a terminal status, so the wait timed out or a poll call errored. + nonTerminal := func() { + if errors.Is(err, context.DeadlineExceeded) { + res.status, res.err = mirrorStatusTimedOut, err + } else { + res.status, res.err = mirrorStatusError, err } } + switch outcome.status { + case coreapi.MirrorStatusReady: + res.status = mirrorStatusReady + case coreapi.MirrorStatusSuspended: + res.status, res.err = mirrorStatusSuspended, err + case coreapi.MirrorStatusFailed: + res.status, res.err = mirrorStatusFailed, err + case coreapi.MirrorStatusProcessing: + nonTerminal() + default: + nonTerminal() + } return res } diff --git a/cmd/entire/cli/repo_mirror_probe.go b/cmd/entire/cli/repo_mirror_probe.go index 001c46f0ba..494a215176 100644 --- a/cmd/entire/cli/repo_mirror_probe.go +++ b/cmd/entire/cli/repo_mirror_probe.go @@ -5,15 +5,11 @@ import ( "errors" "fmt" "io" - "net/http" "regexp" "strings" "time" - "github.com/go-git/go-git/v6/plumbing/protocol/packp" - - "github.com/entireio/cli/cmd/entire/cli/auth" - "github.com/entireio/cli/internal/entireclient/discovery" + "github.com/entireio/cli/internal/coreapi" ) // gitHubHTTPSRe / gitHubSSHRe / gitHubBareRe parse the GitHub URL shapes @@ -28,12 +24,12 @@ import ( // // The owner/repo capture groups are restricted to GitHub's real identifier // charset rather than a permissive "anything but slash". owner/repo flow -// unescaped into the STS audience (auth.RepoScopedToken) and the smart-HTTP -// probe URL (waitForMirrorClone); a loose pattern would admit ?, #, %, .. and -// control chars, letting a name like `repo?bypass=1` smuggle a query string -// or `repo#x` truncate the path. GitHub owners are [A-Za-z0-9-] and repos are -// [A-Za-z0-9._-], so matching upstream reality closes those vectors at the -// boundary instead of relying on whatever the server does with weird strings. +// unescaped into the STS audience (auth.RepoScopedToken) and the clone URL; +// a loose pattern would admit ?, #, %, .. and control chars, letting a name +// like `repo?bypass=1` smuggle a query string or `repo#x` truncate the path. +// GitHub owners are [A-Za-z0-9-] and repos are [A-Za-z0-9._-], so matching +// upstream reality closes those vectors at the boundary instead of relying on +// whatever the server does with weird strings. const ( gitHubOwnerPat = `([A-Za-z0-9-]+)` gitHubRepoPat = `([A-Za-z0-9._-]+?)` @@ -67,256 +63,97 @@ func parseGitHubURL(rawURL string) (owner, repo string, err error) { return "", "", fmt.Errorf("not a recognized GitHub URL: %s", rawURL) } -// probeInterval is the cadence between info/refs probes during the -// clone wait. minReauthInterval floors how often we'll re-mint a -// repo-scoped token after a 401: STS rate-limits or auth flapping -// during a long wait shouldn't be amplified by the 2s probe cadence. -const ( - probeInterval = 2 * time.Second - minReauthInterval = 30 * time.Second -) - -// maxProbeBytes bounds the smart-HTTP info/refs body read so a -// pathological or misbehaving server can't make us allocate without -// limit. Real ref advertisements for the largest repos sit well under -// this; the cap is sized for headroom, not snugness. -const maxProbeBytes = 8 << 20 // 8 MiB - -// probeClient is the HTTP client used for the clone-readiness loop. It is -// purpose-built (own Transport, explicit timeouts, no redirect surprises) -// instead of http.DefaultClient: DefaultClient is process-global and can -// be poisoned by other code, and its zero-Timeout default would let a -// stuck connection consume one ticker slot indefinitely. Each probe is -// already context-bound by the loop's deadline, but Timeout is a belt to -// the context's braces for the connection/TLS phase. -var probeClient = &http.Client{ - Timeout: 15 * time.Second, - Transport: &http.Transport{ - // One mirror, repeated probes — a tiny idle pool is enough to - // reuse the TLS session between ticks without leaking conns. - MaxIdleConns: 2, - MaxIdleConnsPerHost: 2, - IdleConnTimeout: 90 * time.Second, - TLSHandshakeTimeout: 10 * time.Second, - }, - // The cluster front door 307-redirects info/refs to the node holding the - // mirror (e.g. bishop.); git follows these to clone, so the - // probe must too. Refusing them (the old http.ErrUseLastResponse) made - // mirrorAdvertisesHead read the 307 as "not 200, not ready" and spin the - // cloning-dots forever even after the clone had landed. - CheckRedirect: checkProbeRedirect, -} +// mirrorPollInterval is the cadence between mirror-status polls while waiting +// for the initial clone. A package var (not const) so tests can shorten it. +var mirrorPollInterval = 2 * time.Second -// maxProbeRedirects bounds redirect-following during the clone probe. -// One front-door→node hop is expected; the cap is headroom against a -// misconfigured loop, not snugness. -const maxProbeRedirects = 5 - -// checkProbeRedirect confines the clone probe's redirect-following to the -// cluster trust domain, over https. The 307 the front door issues carries the -// pull token in the Location userinfo; following it out of the cluster would -// hand that token to whoever the redirect named. discovery.HostInCluster is -// the same boundary the entire:// transport enforces on its own redirects. -func checkProbeRedirect(req *http.Request, via []*http.Request) error { - if len(via) >= maxProbeRedirects { - return fmt.Errorf("stopped after %d redirects", maxProbeRedirects) - } - if req.URL.Scheme != schemeHTTPS { - return fmt.Errorf("refusing non-https redirect to %s", req.URL.Redacted()) - } - if !discovery.HostInCluster(req.URL.Hostname(), via[0].URL.Hostname()) { - return fmt.Errorf("refusing cross-host redirect to %s", req.URL.Redacted()) - } - return nil -} - -// explainSuspendedMirror translates a clone-probe failure into a clear, -// actionable message when the cluster reports no servable mirror -// (auth.ErrRepoTargetUnknown) for a placement create just confirmed exists. -// That pairing — "Mirror already exists" from create, then a refused token -// exchange — is the signature of a suspended placement: create is idempotent -// on (repo, cluster) and ignores suspended_at, while the auth gate hides -// suspended mirrors behind invalid_target. Recovery is operator-side, so we -// name the exact resume command rather than leaking the raw OAuth error. -// -// freshCreate gates the diagnosis: a mirror created moments ago cannot be -// suspended (suspension only happens after upstream access is lost), so an -// invalid_target on a fresh create is propagation lag, not suspension. -// Diagnosing that as "suspended" would misdirect the user to a resume command -// that does nothing, so we decline (handled=false) and let the raw error surface. -// -// Returns handled=false for any other error so the caller surfaces it -// verbatim. When handled, the message is already written to w and the -// returned error is a SilentError so main.go won't reprint it. -func explainSuspendedMirror(w io.Writer, mirrorID string, freshCreate bool, err error) (bool, error) { - if freshCreate || !errors.Is(err, auth.ErrRepoTargetUnknown) { - return false, nil - } - fmt.Fprintf(w, - "\nMirror %s is registered but the cluster won't issue clone tokens for it.\n"+ - "This usually means the placement is suspended after upstream GitHub access\n"+ - "was lost (App uninstalled, the repo went private, or a transient API error).\n"+ - "An operator can re-enable it once access is restored:\n"+ - " entire-core admin mirrors resume %s\n", - mirrorID, mirrorID) - return true, NewSilentError(fmt.Errorf("mirror %s is suspended", mirrorID)) -} - -// repoTokenSource is the subset of auth.RepoTokenSource the clone probe -// uses; an interface so tests can substitute a fake. -type repoTokenSource interface { - Token(ctx context.Context, repoSlug, action string) (string, error) - Invalidate(repoSlug, action string) -} +var ( + // errMirrorCloneFailed reports the mirror's initial clone reached the + // terminal "failed" status — the server gave up cloning the upstream. + errMirrorCloneFailed = errors.New("initial clone failed") + // errMirrorSuspended reports the placement is suspended: registered, but the + // cluster won't serve it. Recovery is operator-side (explainSuspendedMirror). + errMirrorSuspended = errors.New("mirror is suspended") +) -// newRepoTokenSource builds the clone probe's token source. Package var so -// tests can substitute a slow or failing auth path. -var newRepoTokenSource = func(ctx context.Context, clusterHost string) (repoTokenSource, error) { - return auth.NewRepoTokenSource(ctx, clusterHost) +// mirrorStatusGetter is the slice of *coreapi.Client that awaitMirrorReady +// needs, declared as an interface so the poll is unit-testable with a fake. +type mirrorStatusGetter interface { + GetMirror(ctx context.Context, params coreapi.GetMirrorParams) (*coreapi.Mirror, error) } -// waitForMirrorClone blocks until the mirror at /gh// on -// clusterHost advertises a resolvable HEAD (the initial GitHub→EntireDB -// clone has landed) or the deadline expires. It probes the data plane's -// smart-HTTP info/refs endpoint every 2s, printing a heartbeat so a long -// clone doesn't look hung. +// awaitMirrorReady polls the control plane for a mirror's clone lifecycle until +// it reaches a terminal status or the deadline/cancellation fires. It returns +// the last observed status plus: // -// Repo-scoped pull tokens are short-lived (minutes) while the default wait -// is 30m, so a single token can't cover the whole wait. The loop re-mints -// from the (long-lived) login token whenever a probe comes back 401, rather -// than minting once up front. Cluster discovery and context selection run -// once, when the source is built — a discovery hiccup mid-wait can't abort -// a wait that already authorized. Re-mints are floored at minReauthInterval -// so a flapping STS can't be amplified into a re-mint every probeInterval -// ticks. +// - nil when ready (the repo is clonable) +// - errMirrorCloneFailed when the initial clone failed +// - errMirrorSuspended when the placement is suspended +// - a timeout/transport err when the wait deadline passed or a poll errored // -// The deadline is applied before the source is built: authorization spans -// cluster discovery and a possible login refresh, so it must consume the -// user's wait budget, not run before it. -func waitForMirrorClone(ctx context.Context, out io.Writer, clusterHost, owner, repo string, timeout time.Duration) error { +// "processing" keeps the loop running. This replaces the old smart-HTTP +// info/refs probe: the control plane now reports clone readiness directly via +// Mirror.status, so a single authenticated control-plane call per tick suffices +// — no repo-scoped token exchange or data-plane round trip. +func awaitMirrorReady(ctx context.Context, c mirrorStatusGetter, mirrorID string, timeout time.Duration) (coreapi.MirrorStatus, error) { if timeout > 0 { var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, timeout) defer cancel() } - - repoSlug := "/gh/" + owner + "/" + repo - checkURL := fmt.Sprintf("https://%s%s/info/refs?service=git-upload-pack", clusterHost, repoSlug) - - src, err := newRepoTokenSource(ctx, clusterHost) - if err != nil { - return fmt.Errorf("authorize clone probe: %w", err) - } - token, err := src.Token(ctx, repoSlug, "pull") - if err != nil { - return fmt.Errorf("authorize clone probe: %w", err) - } - lastMint := time.Now() - - ticker := time.NewTicker(probeInterval) + ticker := time.NewTicker(mirrorPollInterval) defer ticker.Stop() - cloning := false + var last coreapi.MirrorStatus for { - ready, status := mirrorAdvertisesHead(ctx, probeClient, checkURL, token) - switch { - case ready: - if cloning { - fmt.Fprintln(out, " ready") - } else { - fmt.Fprintln(out, " ready to use") - } - return nil - case status == http.StatusUnauthorized: - // The repo-scoped token expired mid-wait; mint a fresh one from - // the login token (no dot, since this is a token refresh, not - // clone progress). Skip the re-mint if we just refreshed — - // otherwise an STS hiccup that 401s every probe would mint on - // every probeInterval tick. - if since := time.Since(lastMint); since < minReauthInterval { - break - } - // Drop the cached token first: the cluster rejected it ahead of - // its recorded expiry, so a plain Token call could replay it. - src.Invalidate(repoSlug, "pull") - newToken, mintErr := src.Token(ctx, repoSlug, "pull") - if mintErr != nil { - if cloning { - fmt.Fprintln(out) - } - return fmt.Errorf("re-authorize clone probe: %w", mintErr) + m, err := c.GetMirror(ctx, coreapi.GetMirrorParams{MirrorId: mirrorID}) + if err != nil { + if ctx.Err() != nil { + return last, classifyWaitContextErr(ctx.Err()) } - token = newToken - lastMint = time.Now() - default: - if !cloning { - fmt.Fprint(out, " cloning") - cloning = true + return last, fmt.Errorf("poll mirror status: %w", err) + } + if s, ok := m.Status.Get(); ok { + last = s + switch s { + case coreapi.MirrorStatusReady: + return s, nil + case coreapi.MirrorStatusFailed: + return s, errMirrorCloneFailed + case coreapi.MirrorStatusSuspended: + return s, errMirrorSuspended + case coreapi.MirrorStatusProcessing: + // keep waiting } - fmt.Fprint(out, ".") } select { case <-ctx.Done(): - fmt.Fprintln(out) - // User cancellation (Ctrl+C) should exit quietly, not print a - // "timed out…: context canceled" line. NewSilentError signals - // main.go to skip printing; a real deadline still reports. - if errors.Is(ctx.Err(), context.Canceled) { - return NewSilentError(ctx.Err()) - } - return fmt.Errorf("timed out waiting for initial clone: %w", ctx.Err()) + return last, classifyWaitContextErr(ctx.Err()) case <-ticker.C: } } } -// mirrorAdvertisesHead fetches the smart-HTTP ref advertisement and reports -// whether HEAD resolves to a real commit, plus the HTTP status so the -// caller can distinguish an expired token (401, re-mint) from a -// still-cloning mirror (200 with no resolvable HEAD, or 404/503). status is -// 0 for transport/build/decode failures, treated as "not ready, keep -// waiting". Auth is the repo-scoped token as HTTP basic-auth password, the -// same shape git presents over the entire:// transport. -func mirrorAdvertisesHead(ctx context.Context, client *http.Client, checkURL, token string) (ready bool, status int) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, checkURL, nil) - if err != nil { - return false, 0 +// classifyWaitContextErr maps the clone wait's context error to a user-facing +// error: a user Ctrl+C exits quietly (SilentError, so main.go doesn't reprint +// it), while a real deadline reports the timeout. +func classifyWaitContextErr(err error) error { + if errors.Is(err, context.Canceled) { + return NewSilentError(err) } - req.SetBasicAuth("entire-cli", token) - resp, err := client.Do(req) - if err != nil { - return false, 0 - } - // Drain before close so the transport can return the connection to the - // idle pool and reuse the TLS session on the next tick. Go only recycles - // a conn whose body was read to EOF before Close, and the Decode error - // returns below leave the body partially read. Drain to EOF, uncapped: - // the maxProbeBytes cap on the read below bounds the Decode *allocation*, - // but draining to io.Discard is O(1) memory, and a LimitReader cap - // shorter than the body would stop before EOF and silently defeat reuse. - // The client Timeout still bounds how long the drain can run. - defer func() { - _, _ = io.Copy(io.Discard, resp.Body) //nolint:errcheck // best-effort drain to enable conn reuse; copy errors are irrelevant - _ = resp.Body.Close() - }() - if resp.StatusCode != http.StatusOK { - return false, resp.StatusCode - } - // Cap the body to prevent unbounded reads; ogen's api/client.go does - // the same for JSON. Smart-HTTP wraps the advertisement in a "# - // service=..." pkt-line header + flush; AdvRefs.Decode expects to - // start at the first ref line, so strip the wrapper first. - body := io.LimitReader(resp.Body, maxProbeBytes) - var sr packp.SmartReply - if err := sr.Decode(body); err != nil { - return false, 0 - } - var adv packp.AdvRefs - if err := adv.Decode(body); err != nil { - return false, 0 - } - if _, err := adv.ResolvedHead(); err != nil { - return false, http.StatusOK // reachable, clone still in progress - } - return true, http.StatusOK + return fmt.Errorf("timed out waiting for initial clone: %w", err) +} + +// explainSuspendedMirror prints operator-recovery guidance for a suspended +// placement: the cluster won't serve clones until upstream GitHub access is +// restored and an operator resumes it. Suspension usually follows upstream +// access loss (App uninstalled, repo went private, or a transient API error). +func explainSuspendedMirror(w io.Writer, mirrorID string) { + fmt.Fprintf(w, + "\nMirror %s is registered but suspended — the cluster won't serve it.\n"+ + "This usually means upstream GitHub access was lost (App uninstalled,\n"+ + "the repo went private, or a transient API error). An operator can\n"+ + "re-enable it once access is restored:\n"+ + " entire-core admin mirrors resume %s\n", + mirrorID, mirrorID) } diff --git a/cmd/entire/cli/repo_mirror_test.go b/cmd/entire/cli/repo_mirror_test.go index eb38be87ec..c2044de56c 100644 --- a/cmd/entire/cli/repo_mirror_test.go +++ b/cmd/entire/cli/repo_mirror_test.go @@ -4,218 +4,171 @@ import ( "bytes" "context" "errors" - "fmt" - "io" - "net" "net/http" "net/http/httptest" "net/url" - "strings" - "sync/atomic" "testing" "time" "github.com/stretchr/testify/require" - "github.com/entireio/cli/cmd/entire/cli/auth" "github.com/entireio/cli/internal/coreapi" ) func TestExplainSuspendedMirror(t *testing.T) { t.Parallel() const id = "01KS6KFJR2XS6PZ188MVYE07AN" + var buf bytes.Buffer + explainSuspendedMirror(&buf, id) + out := buf.String() + require.Contains(t, out, id, "message must name the mirror") + require.Contains(t, out, "entire-core admin mirrors resume "+id, "message must give the resume command") +} - t.Run("suspended mirror is explained with resume command", func(t *testing.T) { - t.Parallel() - // Wrap the sentinel the way RepoScopedToken/waitForMirrorClone do, to - // prove detection survives the wrapping chain. - err := fmt.Errorf("authorize clone probe: %w", fmt.Errorf("repo-scoped token exchange: %w", auth.ErrRepoTargetUnknown)) - var buf bytes.Buffer - handled, serr := explainSuspendedMirror(&buf, id, false, err) - if !handled { - t.Fatal("expected handled=true for ErrRepoTargetUnknown") - } - var silent *SilentError - if !errors.As(serr, &silent) { - t.Errorf("expected a SilentError, got %T: %v", serr, serr) - } - out := buf.String() - if !strings.Contains(out, id) { - t.Errorf("message %q omits the mirror id", out) - } - if !strings.Contains(out, "entire-core admin mirrors resume "+id) { - t.Errorf("message %q omits the resume command", out) - } +// fakeMirrorGetter feeds awaitMirrorReady a scripted sequence of statuses (the +// last entry repeats) or a fixed error, standing in for *coreapi.Client.GetMirror. +type fakeMirrorGetter struct { + statuses []coreapi.MirrorStatus + err error + calls int +} + +func (f *fakeMirrorGetter) GetMirror(_ context.Context, _ coreapi.GetMirrorParams) (*coreapi.Mirror, error) { + if f.err != nil { + return nil, f.err + } + i := f.calls + if i >= len(f.statuses) { + i = len(f.statuses) - 1 + } + f.calls++ + m := &coreapi.Mirror{} + m.Status = coreapi.NewOptMirrorStatus(f.statuses[i]) + return m, nil +} + +// TestAwaitMirrorReady covers the clone-status poll that replaced the info/refs +// probe: terminal statuses resolve, processing keeps polling, and an exhausted +// deadline reports a timeout. +// +// Not parallel: shortens the package-level mirrorPollInterval. +func TestAwaitMirrorReady(t *testing.T) { + prev := mirrorPollInterval + mirrorPollInterval = time.Millisecond + t.Cleanup(func() { mirrorPollInterval = prev }) + ctx := t.Context() + + t.Run("ready resolves with no error", func(t *testing.T) { + f := &fakeMirrorGetter{statuses: []coreapi.MirrorStatus{coreapi.MirrorStatusReady}} + status, err := awaitMirrorReady(ctx, f, "m", time.Second) + require.NoError(t, err) + require.Equal(t, coreapi.MirrorStatusReady, status) }) - t.Run("fresh create passes invalid_target through as propagation lag", func(t *testing.T) { - t.Parallel() - // Same invalid_target signature, but on a just-created placement it's - // eventual-consistency lag, not suspension — don't misdirect to resume. - err := fmt.Errorf("authorize clone probe: %w", fmt.Errorf("repo-scoped token exchange: %w", auth.ErrRepoTargetUnknown)) - var buf bytes.Buffer - handled, serr := explainSuspendedMirror(&buf, id, true, err) - if handled { - t.Error("expected handled=false for a fresh create") - } - if serr != nil { - t.Errorf("expected nil error, got %v", serr) - } - if buf.Len() != 0 { - t.Errorf("expected no output, got %q", buf.String()) - } + t.Run("processing then ready keeps polling", func(t *testing.T) { + f := &fakeMirrorGetter{statuses: []coreapi.MirrorStatus{ + coreapi.MirrorStatusProcessing, coreapi.MirrorStatusProcessing, coreapi.MirrorStatusReady, + }} + status, err := awaitMirrorReady(ctx, f, "m", time.Second) + require.NoError(t, err) + require.Equal(t, coreapi.MirrorStatusReady, status) + require.GreaterOrEqual(t, f.calls, 3) }) - t.Run("unrelated error passes through untouched", func(t *testing.T) { - t.Parallel() - var buf bytes.Buffer - handled, serr := explainSuspendedMirror(&buf, id, false, errors.New("timed out waiting for initial clone")) - if handled { - t.Error("expected handled=false for an unrelated error") - } - if serr != nil { - t.Errorf("expected nil error, got %v", serr) - } - if buf.Len() != 0 { - t.Errorf("expected no output, got %q", buf.String()) - } + t.Run("failed returns errMirrorCloneFailed", func(t *testing.T) { + f := &fakeMirrorGetter{statuses: []coreapi.MirrorStatus{coreapi.MirrorStatusFailed}} + status, err := awaitMirrorReady(ctx, f, "m", time.Second) + require.ErrorIs(t, err, errMirrorCloneFailed) + require.Equal(t, coreapi.MirrorStatusFailed, status) + }) + + t.Run("suspended returns errMirrorSuspended", func(t *testing.T) { + f := &fakeMirrorGetter{statuses: []coreapi.MirrorStatus{coreapi.MirrorStatusSuspended}} + status, err := awaitMirrorReady(ctx, f, "m", time.Second) + require.ErrorIs(t, err, errMirrorSuspended) + require.Equal(t, coreapi.MirrorStatusSuspended, status) + }) + + t.Run("never-ready times out", func(t *testing.T) { + f := &fakeMirrorGetter{statuses: []coreapi.MirrorStatus{coreapi.MirrorStatusProcessing}} + _, err := awaitMirrorReady(ctx, f, "m", 20*time.Millisecond) + require.ErrorIs(t, err, context.DeadlineExceeded) }) } -// TestFinishMirrorCreate exercises the post-create branching: when the -// upstream is empty we must skip the HEAD-poll loop (an empty repo never -// advertises a HEAD), yet an *existing* empty placement must still go through -// the token exchange so a suspended mirror surfaces its resume guidance -// instead of a success-style "nothing to clone" note. -func TestFinishMirrorCreate(t *testing.T) { +// TestReportOneShotMirror exercises the one-shot create's presentation across +// the shared lifecycle outcomes — the branching finishMirrorCreate used to own, +// now driven by mirrorCreateOutcome (and shared with the wizard). +func TestReportOneShotMirror(t *testing.T) { t.Parallel() - const id = "01KS6KFJR2XS6PZ188MVYE07AN" const mirrorURL = "entire://eu-west-1.entire.io/gh/octocat/hello-world" - // The error shape RepoScopedToken/waitForMirrorClone produce for a - // suspended (non-servable) placement. - suspended := fmt.Errorf("repo-scoped token exchange: %w", auth.ErrRepoTargetUnknown) - - // seen records whether each injected operation ran, so we can assert the - // empty path never polls and a fresh create never probes. - type call struct{ probed, waited bool } + mk := func(created, empty bool) *coreapi.CreatedMirror { + return &coreapi.CreatedMirror{Created: created, Empty: empty, MirrorId: id, MirrorUrl: mirrorURL} + } - t.Run("fresh empty create skips both probe and poll", func(t *testing.T) { + t.Run("create failure surfaces with nothing printed", func(t *testing.T) { t.Parallel() - var seen call var out, errW bytes.Buffer - created := &coreapi.CreatedMirror{Created: true, Empty: true, MirrorId: id, MirrorUrl: mirrorURL} - err := finishMirrorCreate(&out, &errW, created, false, - func() error { seen.probed = true; return nil }, - func() error { seen.waited = true; return nil }, - ) - require.NoError(t, err) - require.False(t, seen.probed, "a fresh create can't be suspended; must not probe") - require.False(t, seen.waited, "empty upstream has nothing to clone; must not poll") - require.Contains(t, out.String(), "nothing to clone") - require.Empty(t, errW.String()) + wantErr := errors.New("boom") + err := reportOneShotMirror(&out, &errW, mirrorCreateOutcome{}, wantErr) + require.ErrorIs(t, err, wantErr) + require.Empty(t, out.String()) }) - t.Run("existing empty healthy probes but does not poll", func(t *testing.T) { + t.Run("empty upstream prints nothing-to-clone", func(t *testing.T) { t.Parallel() - var seen call var out, errW bytes.Buffer - created := &coreapi.CreatedMirror{Created: false, Empty: true, MirrorId: id, MirrorUrl: mirrorURL} - err := finishMirrorCreate(&out, &errW, created, false, - func() error { seen.probed = true; return nil }, - func() error { seen.waited = true; return nil }, - ) + err := reportOneShotMirror(&out, &errW, mirrorCreateOutcome{created: mk(true, true)}, nil) require.NoError(t, err) - require.True(t, seen.probed, "existing empty placement must probe for suspension") - require.False(t, seen.waited, "empty upstream has nothing to clone; must not poll") - require.Contains(t, out.String(), "nothing to clone") - }) - - t.Run("existing empty suspended surfaces resume guidance", func(t *testing.T) { - t.Parallel() - var seen call - var out, errW bytes.Buffer - created := &coreapi.CreatedMirror{Created: false, Empty: true, MirrorId: id, MirrorUrl: mirrorURL} - err := finishMirrorCreate(&out, &errW, created, false, - func() error { seen.probed = true; return suspended }, - func() error { seen.waited = true; return nil }, - ) - var silent *SilentError - require.ErrorAs(t, err, &silent, "suspended mirror must return a SilentError") - require.True(t, seen.probed) - require.False(t, seen.waited, "must not poll a suspended empty mirror") - require.Contains(t, errW.String(), "entire-core admin mirrors resume "+id) - require.NotContains(t, out.String(), "nothing to clone", - "a suspended mirror must not get the success-style empty note") - }) - - t.Run("existing empty transient probe error is non-fatal", func(t *testing.T) { - t.Parallel() - var out, errW bytes.Buffer - created := &coreapi.CreatedMirror{Created: false, Empty: true, MirrorId: id, MirrorUrl: mirrorURL} - err := finishMirrorCreate(&out, &errW, created, false, - func() error { return errors.New("dial tcp: connection refused") }, - func() error { t.Fatal("must not poll an empty mirror"); return nil }, - ) - require.NoError(t, err, "a non-suspension probe error must not fail a create whose placement exists") + require.Contains(t, out.String(), "Registered mirror "+id) require.Contains(t, out.String(), "nothing to clone") }) - t.Run("non-empty no-wait skips both probe and poll", func(t *testing.T) { + t.Run("no-wait prints in-progress hint", func(t *testing.T) { t.Parallel() - var seen call var out, errW bytes.Buffer - created := &coreapi.CreatedMirror{Created: true, Empty: false, MirrorId: id, MirrorUrl: mirrorURL} - err := finishMirrorCreate(&out, &errW, created, true, - func() error { seen.probed = true; return nil }, - func() error { seen.waited = true; return nil }, - ) + err := reportOneShotMirror(&out, &errW, mirrorCreateOutcome{created: mk(true, false)}, nil) require.NoError(t, err) - require.False(t, seen.probed) - require.False(t, seen.waited, "--no-wait must not poll") require.Contains(t, out.String(), "still be in progress") }) - t.Run("non-empty waits for clone then prints clone hint", func(t *testing.T) { + t.Run("ready prints clone hint", func(t *testing.T) { t.Parallel() - var seen call var out, errW bytes.Buffer - created := &coreapi.CreatedMirror{Created: true, Empty: false, MirrorId: id, MirrorUrl: mirrorURL} - err := finishMirrorCreate(&out, &errW, created, false, - func() error { seen.probed = true; return nil }, - func() error { seen.waited = true; return nil }, - ) + outcome := mirrorCreateOutcome{created: mk(true, false), status: coreapi.MirrorStatusReady, polled: true} + err := reportOneShotMirror(&out, &errW, outcome, nil) require.NoError(t, err) - require.False(t, seen.probed, "non-empty path detects suspension through waitClone, not a separate probe") - require.True(t, seen.waited) require.Contains(t, out.String(), "git clone "+mirrorURL) }) - t.Run("non-empty existing suspended surfaces resume guidance", func(t *testing.T) { + t.Run("suspended surfaces resume guidance as SilentError", func(t *testing.T) { t.Parallel() var out, errW bytes.Buffer - created := &coreapi.CreatedMirror{Created: false, Empty: false, MirrorId: id, MirrorUrl: mirrorURL} - err := finishMirrorCreate(&out, &errW, created, false, - func() error { return nil }, - func() error { return suspended }, - ) + outcome := mirrorCreateOutcome{created: mk(false, false), status: coreapi.MirrorStatusSuspended, polled: true} + err := reportOneShotMirror(&out, &errW, outcome, errMirrorSuspended) var silent *SilentError require.ErrorAs(t, err, &silent) require.Contains(t, errW.String(), "entire-core admin mirrors resume "+id) require.NotContains(t, out.String(), "git clone") }) - t.Run("non-empty wait error other than suspension propagates", func(t *testing.T) { + t.Run("failed returns an error naming the mirror", func(t *testing.T) { + t.Parallel() + var out, errW bytes.Buffer + outcome := mirrorCreateOutcome{created: mk(true, false), status: coreapi.MirrorStatusFailed, polled: true} + err := reportOneShotMirror(&out, &errW, outcome, errMirrorCloneFailed) + require.Error(t, err) + require.Contains(t, err.Error(), id) + }) + + t.Run("timeout propagates the wait error", func(t *testing.T) { t.Parallel() var out, errW bytes.Buffer - created := &coreapi.CreatedMirror{Created: true, Empty: false, MirrorId: id, MirrorUrl: mirrorURL} wantErr := errors.New("timed out waiting for initial clone") - err := finishMirrorCreate(&out, &errW, created, false, - func() error { return nil }, - func() error { return wantErr }, - ) + outcome := mirrorCreateOutcome{created: mk(true, false), status: coreapi.MirrorStatusProcessing, polled: true} + err := reportOneShotMirror(&out, &errW, outcome, wantErr) require.ErrorIs(t, err, wantErr) - require.Empty(t, errW.String()) }) } @@ -629,167 +582,3 @@ func TestValidateClusterHost(t *testing.T) { }) } } - -// TestMirrorAdvertisesHead_ReusesConnection is the regression test for the -// body drain in mirrorAdvertisesHead. The probe runs every 2s for up to 30m, -// and probeClient keeps a small idle pool specifically to reuse the TLS -// session across ticks — but Go only returns a connection to that pool when -// the response body is read to EOF before Close. If the drain is removed (or -// re-capped shorter than the body), the body is left partially read and the -// transport closes the connection instead, so every probe pays a fresh -// handshake. -// -// We serve a non-200 with a non-empty body: mirrorAdvertisesHead returns at -// the status check without reading the body itself, so the deferred drain is -// the *only* thing that consumes it. Counting StateNew transitions then -// distinguishes "drained → one reused connection" from "not drained → a new -// connection per call". -func TestMirrorAdvertisesHead_ReusesConnection(t *testing.T) { - t.Parallel() - - // Larger than any plausible "small cap" someone might reintroduce, so a - // capped drain would stop before EOF and fail this test too. - body := strings.Repeat("x", 64<<10) - - var newConns atomic.Int64 - srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - // 503 = mirror reachable but not ready; the function returns at the - // status check below http.StatusOK without touching the body. - w.WriteHeader(http.StatusServiceUnavailable) - _, _ = w.Write([]byte(body)) //nolint:errcheck // test server write; failure surfaces as a client error - })) - srv.Config.ConnState = func(_ net.Conn, state http.ConnState) { - if state == http.StateNew { - newConns.Add(1) - } - } - srv.Start() - defer srv.Close() - - // Isolated client (not the package-global probeClient) so the idle pool - // is private to this test and the assertion stays deterministic under - // t.Parallel(). Keep-alives and idle pooling are on by default. - client := &http.Client{ - Timeout: 15 * time.Second, - Transport: &http.Transport{MaxIdleConns: 2, MaxIdleConnsPerHost: 2, IdleConnTimeout: 90 * time.Second}, - } - - const probes = 5 - for range probes { - ready, status := mirrorAdvertisesHead(context.Background(), client, srv.URL, "tok") - require.False(t, ready) - require.Equal(t, http.StatusServiceUnavailable, status) - } - - require.Equal(t, int64(1), newConns.Load(), - "expected the drained body to let all %d probes share one connection; got %d new connections (body not drained to EOF?)", - probes, newConns.Load()) -} - -// pktLine encodes s as a git pkt-line (4-hex length prefix including the -// prefix itself), mirroring what a smart-HTTP server writes. -func pktLine(s string) string { return fmt.Sprintf("%04x%s", len(s)+4, s) } - -// uploadPackAdvertisement returns a minimal but valid git-upload-pack -// info/refs body: the "# service" banner, a flush, a HEAD line carrying the -// symref capability, a refs/heads/main line, and a trailing flush. It decodes -// to an AdvRefs whose HEAD resolves to refs/heads/main. -func uploadPackAdvertisement() string { - const sha = "d9a69831082341eab799c062e10ad28b3204c08a" - return pktLine("# service=git-upload-pack\n") + - "0000" + - pktLine(sha+" HEAD\x00symref=HEAD:refs/heads/main\n") + - pktLine(sha+" refs/heads/main\n") + - "0000" -} - -// TestMirrorAdvertisesHead_FollowsNodeRedirect is the regression test for the -// infinite cloning-dots bug. The cluster front door 307-redirects info/refs to -// the node holding the mirror; git follows that to clone. The probe used to -// refuse all redirects (http.ErrUseLastResponse), so it saw the 307 as "not -// 200, not ready" and printed cloning-dots forever even after the clone had -// landed. With checkProbeRedirect the probe follows same-host redirects and -// reaches the advertisement, so it reports ready. -func TestMirrorAdvertisesHead_FollowsNodeRedirect(t *testing.T) { - t.Parallel() - - srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/node/info/refs" { - w.Header().Set("Content-Type", "application/x-git-upload-pack-advertisement") - _, _ = w.Write([]byte(uploadPackAdvertisement())) //nolint:errcheck // test server write - return - } - // Front door: route to the backing node on the same host, the way the - // real cluster front door 307s to bishop.. - http.Redirect(w, r, "/node/info/refs", http.StatusTemporaryRedirect) - })) - defer srv.Close() - - // srv.Client() trusts the test cert; layer on the production redirect - // policy so we exercise the real follow path. - client := srv.Client() - client.CheckRedirect = checkProbeRedirect - - ready, status := mirrorAdvertisesHead(context.Background(), client, srv.URL+"/info/refs", "tok") - require.True(t, ready, "probe should follow the node redirect and see HEAD") - require.Equal(t, http.StatusOK, status) -} - -func TestCheckProbeRedirect(t *testing.T) { - t.Parallel() - - orig := mustReq(t, "https://aws-us-east-2.entire.io/gh/o/r/info/refs") - tests := []struct { - name string - target string - via int - wantErr bool - }{ - {name: "same host", target: "https://aws-us-east-2.entire.io/node/info/refs", via: 1}, - {name: "subdomain node", target: "https://bishop.aws-us-east-2.entire.io/gh/o/r/info/refs", via: 1}, - {name: "cross host leaks token", target: "https://evil.example.com/info/refs", via: 1, wantErr: true}, - {name: "sibling suffix trick", target: "https://aws-us-east-2.entire.io.evil.com/x", via: 1, wantErr: true}, - {name: "non-https", target: "http://bishop.aws-us-east-2.entire.io/x", via: 1, wantErr: true}, - {name: "too many hops", target: "https://bishop.aws-us-east-2.entire.io/x", via: maxProbeRedirects, wantErr: true}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - via := make([]*http.Request, tt.via) - via[0] = orig - err := checkProbeRedirect(mustReq(t, tt.target), via) - if tt.wantErr { - require.Error(t, err) - } else { - require.NoError(t, err) - } - }) - } -} - -func mustReq(t *testing.T, rawURL string) *http.Request { - t.Helper() - u, err := url.Parse(rawURL) - require.NoError(t, err) - return &http.Request{URL: u} -} - -// TestWaitForMirrorClone_TimeoutBoundsAuthorization pins that --wait-timeout -// covers the authorization phase, not just the probe loop: building the -// token source spans cluster discovery and a possible login refresh, so a -// hung auth path must be cut off by the user's wait budget. -// -// Not parallel: swaps the package-level newRepoTokenSource seam. -func TestWaitForMirrorClone_TimeoutBoundsAuthorization(t *testing.T) { - prev := newRepoTokenSource - newRepoTokenSource = func(ctx context.Context, _ string) (repoTokenSource, error) { - <-ctx.Done() // hang until the wait deadline fires - return nil, ctx.Err() - } - t.Cleanup(func() { newRepoTokenSource = prev }) - - start := time.Now() - err := waitForMirrorClone(context.Background(), io.Discard, "cluster.example", "o", "r", 50*time.Millisecond) - require.ErrorIs(t, err, context.DeadlineExceeded) - require.Less(t, time.Since(start), 10*time.Second, "authorization must be bounded by the wait timeout") -} From 289a796c87cc76086b89af3d5c3bdc65a01f3fb5 Mon Sep 17 00:00:00 2001 From: paul <423357+toothbrush@users.noreply.github.com> Date: Thu, 25 Jun 2026 11:53:50 +0930 Subject: [PATCH 05/17] Default one-shot create cluster to the catalog default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `entire repo mirror create github.com/octocat/hello-world` (no cluster-host) now resolves the target from GET /api/v1/clusters — the is_default cluster (or the sole one) — instead of the hardcoded constant, matching the wizard. Prints the chosen cluster; errors asking for an explicit [cluster-host] when the catalog is empty or has no clear default. Co-Authored-By: Claude Opus 4.8 (1M context) Entire-Checkpoint: cfcf2050076d --- cmd/entire/cli/repo_mirror.go | 65 +++++++++++++++++++++++++++--- cmd/entire/cli/repo_mirror_test.go | 35 ++++++++++++++++ 2 files changed, 94 insertions(+), 6 deletions(-) diff --git a/cmd/entire/cli/repo_mirror.go b/cmd/entire/cli/repo_mirror.go index 6f4c8402f4..1060100946 100644 --- a/cmd/entire/cli/repo_mirror.go +++ b/cmd/entire/cli/repo_mirror.go @@ -145,8 +145,8 @@ func newRepoMirrorCreateCmd() *cobra.Command { "the target cluster, then waits for the initial GitHub→EntireDB clone " + "to finish so `git clone` works on return. Pass --no-wait to return " + "as soon as the placement is registered. Idempotent on " + - "(upstream, cluster). The cluster-host defaults to " + - defaultClusterHost + " when omitted.", + "(upstream, cluster). The cluster-host defaults to your catalog's " + + "default cluster when omitted.", Example: " entire repo mirror create\n" + " entire repo mirror create github.com/octocat/hello-world\n" + " entire repo mirror create github.com/octocat/hello-world eu-west-1.entire.io", @@ -160,10 +160,29 @@ func newRepoMirrorCreateCmd() *cobra.Command { cmd.SilenceUsage = true return fmt.Errorf("invalid : %w", err) } - clusterHost := clusterArg(args) - if err := validateClusterHost(clusterHost); err != nil { - cmd.SilenceUsage = true - return fmt.Errorf("invalid [cluster-host]: %w", err) + var clusterHost string + if len(args) > 1 { + clusterHost = args[1] + if err := validateClusterHost(clusterHost); err != nil { + cmd.SilenceUsage = true + return fmt.Errorf("invalid [cluster-host]: %w", err) + } + } else { + // No cluster given: pick the catalog's default cluster (the same + // GET /api/v1/clusters source the wizard uses), resolved via the + // active context. runCore owns the active-client preamble + // (silence-usage, --insecure-http-auth, error mapping). + if err := runCore(cmd, func(ctx context.Context, c *coreapi.Client) error { + h, rerr := resolveDefaultClusterHost(ctx, c) + if rerr != nil { + return rerr + } + clusterHost = h + fmt.Fprintf(cmd.ErrOrStderr(), "Using default cluster %s\n", clusterHost) + return nil + }); err != nil { + return err + } } return runCoreForCluster(cmd, clusterHost, func(ctx context.Context, c *coreapi.Client) error { errW := cmd.ErrOrStderr() @@ -182,6 +201,40 @@ func newRepoMirrorCreateCmd() *cobra.Command { return cmd } +// resolveDefaultClusterHost picks the cluster a `repo mirror create +// ` targets when [cluster-host] is omitted, from the control plane's +// cluster catalog (the same GET /api/v1/clusters the wizard uses): the cluster +// flagged is_default, or the sole cluster when there is exactly one. It errors +// when the catalog is empty or has no clear default so the user knows to pass +// [cluster-host] explicitly, rather than silently guessing. +func resolveDefaultClusterHost(ctx context.Context, c *coreapi.Client) (string, error) { + regions, err := availableRegions(ctx, c) + if err != nil { + return "", err + } + return pickDefaultRegionHost(regions) +} + +// pickDefaultRegionHost chooses the default cluster host from the catalog: the +// is-default cluster, or the sole cluster when there's exactly one. Empty or +// ambiguous catalogs error so the caller can tell the user to pass +// [cluster-host]. Pure, so the selection is unit-testable without a live core. +func pickDefaultRegionHost(regions []regionChoice) (string, error) { + for _, r := range regions { + if r.isDefault { + return r.host, nil + } + } + switch len(regions) { + case 0: + return "", errors.New("no clusters available to mirror into; pass [cluster-host] explicitly") + case 1: + return regions[0].host, nil + default: + return "", errors.New("no default cluster configured; pass [cluster-host] explicitly") + } +} + // mirrorCreateOutcome bundles the create response with the clone status // observed while waiting. polled is false for --no-wait and for empty upstreams, // where there is nothing to await; in those cases status is unset. diff --git a/cmd/entire/cli/repo_mirror_test.go b/cmd/entire/cli/repo_mirror_test.go index c2044de56c..9a7038e5a9 100644 --- a/cmd/entire/cli/repo_mirror_test.go +++ b/cmd/entire/cli/repo_mirror_test.go @@ -452,6 +452,41 @@ func TestAvailableMirrorRow(t *testing.T) { } } +func TestPickDefaultRegionHost(t *testing.T) { + t.Parallel() + + t.Run("prefers the is-default cluster", func(t *testing.T) { + t.Parallel() + host, err := pickDefaultRegionHost([]regionChoice{ + {host: "eu-west-1.entire.io"}, + {host: "aws-us-east-2.entire.io", isDefault: true}, + }) + require.NoError(t, err) + require.Equal(t, "aws-us-east-2.entire.io", host) + }) + + t.Run("sole cluster is the default", func(t *testing.T) { + t.Parallel() + host, err := pickDefaultRegionHost([]regionChoice{{host: "only.entire.io"}}) + require.NoError(t, err) + require.Equal(t, "only.entire.io", host) + }) + + t.Run("empty catalog errors", func(t *testing.T) { + t.Parallel() + _, err := pickDefaultRegionHost(nil) + require.Error(t, err) + }) + + t.Run("ambiguous (multiple, none default) errors", func(t *testing.T) { + t.Parallel() + _, err := pickDefaultRegionHost([]regionChoice{ + {host: "a.entire.io"}, {host: "b.entire.io"}, + }) + require.Error(t, err) + }) +} + func TestClusterArg(t *testing.T) { t.Parallel() if got := clusterArg([]string{"github.com/o/r", "eu-west-1.entire.io"}); got != "eu-west-1.entire.io" { From 9de7d632c9bbd90cef778c01fdd749f102d7e5e3 Mon Sep 17 00:00:00 2001 From: paul <423357+toothbrush@users.noreply.github.com> Date: Sun, 21 Jun 2026 12:45:21 +0930 Subject: [PATCH 06/17] Fix region FQDNs --- cmd/entire/cli/repo_mirror.go | 2 +- cmd/entire/cli/repo_mirror_collaborators.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/entire/cli/repo_mirror.go b/cmd/entire/cli/repo_mirror.go index 1060100946..da1d7d2ef2 100644 --- a/cmd/entire/cli/repo_mirror.go +++ b/cmd/entire/cli/repo_mirror.go @@ -149,7 +149,7 @@ func newRepoMirrorCreateCmd() *cobra.Command { "default cluster when omitted.", Example: " entire repo mirror create\n" + " entire repo mirror create github.com/octocat/hello-world\n" + - " entire repo mirror create github.com/octocat/hello-world eu-west-1.entire.io", + " entire repo mirror create github.com/octocat/hello-world aws-us-east-2.entire.io", Args: cobra.RangeArgs(0, 2), RunE: func(cmd *cobra.Command, args []string) error { if len(args) == 0 { diff --git a/cmd/entire/cli/repo_mirror_collaborators.go b/cmd/entire/cli/repo_mirror_collaborators.go index e8c2f709f7..b8fa15efe7 100644 --- a/cmd/entire/cli/repo_mirror_collaborators.go +++ b/cmd/entire/cli/repo_mirror_collaborators.go @@ -74,7 +74,7 @@ func newRepoMirrorCollaboratorsAddCmd() *cobra.Command { "(pull works without push-through). The cluster-host defaults to " + defaultClusterHost + " when omitted.", Example: " entire repo mirror collaborators add github.com/acme/widget github:alice --role writer\n" + - " entire repo mirror collaborators add github.com/acme/widget github:alice eu-west-1.entire.io --role reader", + " entire repo mirror collaborators add github.com/acme/widget github:alice aws-us-east-2.entire.io --role reader", Args: cobra.RangeArgs(2, 3), RunE: func(cmd *cobra.Command, args []string) error { owner, repo, err := parseGitHubURL(args[0]) From 2b58593b8ea3cee1a1f8ea606f5083b91f294df2 Mon Sep 17 00:00:00 2001 From: paul <423357+toothbrush@users.noreply.github.com> Date: Thu, 25 Jun 2026 12:03:39 +0930 Subject: [PATCH 07/17] Add hidden `entire auth token` for scripting Prints the active control-plane bearer to stdout so curl/scripts can auth without digging the JWT out of the keychain: curl -H "Authorization: Bearer $(entire auth token)" "$CORE/api/v1/clusters" Honors ENTIRE_TOKEN verbatim, else resolves and refreshes the active context's login JWT (same bearer the API client uses). Hidden; errors and the not-logged-in hint go to stderr so stdout stays clean for command substitution. Co-Authored-By: Claude Opus 4.8 (1M context) Entire-Checkpoint: c9e38d421945 --- CLAUDE.md | 6 ++- cmd/entire/cli/auth.go | 43 +++++++++++++++++++++ cmd/entire/cli/auth_token_test.go | 63 +++++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+), 2 deletions(-) create mode 100644 cmd/entire/cli/auth_token_test.go diff --git a/CLAUDE.md b/CLAUDE.md index b5056c90e5..a65f6ac7dd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,8 +42,10 @@ their canonical paths are still runnable. - `configure`: bare prints help and a hint pointing at `entire agent`; flags manage non-agent settings (telemetry, git-hook installation mode, strategy options, summary provider). Agent CRUD lives under `entire agent`. -- `auth`: `login`, `logout`, `status`, `contexts`, `use`. `logout` takes - `--everywhere` (revoke every session on the active core, not just the +- `auth`: `login`, `logout`, `status`, `contexts`, `use`, plus the hidden + `token` (prints the active control-plane bearer to stdout for scripting/curl; + honors `ENTIRE_TOKEN`, else the refreshed active-context login JWT). `logout` + takes `--everywhere` (revoke every session on the active core, not just the current one) and `--all-contexts` (log out of every saved login) - `doctor`: bare runs the scan-and-fix flow, plus `trace`, `logs`, `bundle` diff --git a/cmd/entire/cli/auth.go b/cmd/entire/cli/auth.go index e929804856..1556ab33ac 100644 --- a/cmd/entire/cli/auth.go +++ b/cmd/entire/cli/auth.go @@ -118,11 +118,54 @@ func newAuthCmd() *cobra.Command { cmd.AddCommand(newLoginCmd()) cmd.AddCommand(newLogoutCmd()) cmd.AddCommand(newAuthStatusCmd()) + cmd.AddCommand(newAuthTokenCmd()) cmd.AddCommand(newAuthContextsCmd()) cmd.AddCommand(newAuthUseCmd()) return cmd } +// --- token ------------------------------------------------------------------ + +// newAuthTokenCmd prints the active control-plane bearer to stdout so scripts +// (and ad-hoc curl) can authenticate against the core API without re-deriving +// the keychain slot — e.g. +// +// curl -H "Authorization: Bearer $(entire auth token)" "$CORE/api/v1/clusters" +// +// Hidden: it emits a live credential, so it's a deliberate scripting escape +// hatch, not part of the everyday surface. It resolves the same bearer the API +// client would — ENTIRE_TOKEN verbatim when set, otherwise the active context's +// login JWT, refreshed if it's near expiry — and prints nothing but the token +// (errors and the not-logged-in hint go to stderr) so command substitution +// stays clean. +func newAuthTokenCmd() *cobra.Command { + var insecureHTTPAuth bool + cmd := &cobra.Command{ + Use: "token", + Short: "Print the active control-plane bearer token (for scripting)", + Hidden: true, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + // Refresh may exchange/refresh over the network; honor the + // plain-HTTP opt-in before resolving so local dev cores work. + applyInsecureHTTPAuth(insecureHTTPAuth) + target, err := resolveAuthStatusTarget(cmd.Context(), auth.Contexts, auth.RefreshedLoginToken) + if err != nil { + return err + } + if target.token == "" { + cmd.SilenceUsage = true + fmt.Fprintln(cmd.ErrOrStderr(), "Not logged in. Run 'entire login' to authenticate.") + return NewSilentError(errors.New("not logged in")) + } + fmt.Fprintln(cmd.OutOrStdout(), target.token) + return nil + }, + } + addInsecureHTTPAuthFlag(cmd, &insecureHTTPAuth) + return cmd +} + // --- status ----------------------------------------------------------------- func newAuthStatusCmd() *cobra.Command { diff --git a/cmd/entire/cli/auth_token_test.go b/cmd/entire/cli/auth_token_test.go new file mode 100644 index 0000000000..91399b1662 --- /dev/null +++ b/cmd/entire/cli/auth_token_test.go @@ -0,0 +1,63 @@ +package cli + +import ( + "bytes" + "encoding/base64" + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +// makeTestJWT builds an unsigned JWT with the given payload JSON. ParseClaims +// (used by ENTIRE_TOKEN resolution) reads the payload without verifying the +// signature, so an unsigned token is enough to exercise the resolution path. +func makeTestJWT(t *testing.T, payloadJSON string) string { + t.Helper() + enc := base64.RawURLEncoding + header := enc.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`)) + payload := enc.EncodeToString([]byte(payloadJSON)) + return header + "." + payload + "." + enc.EncodeToString([]byte("sig")) +} + +// TestAuthTokenCmd covers the hidden `entire auth token` scripting helper. +// +// Not parallel: it manipulates ENTIRE_TOKEN / ENTIRE_CONFIG_DIR. +func TestAuthTokenCmd(t *testing.T) { + // Guard against a real ENTIRE_TOKEN in the dev's environment leaking into + // the not-logged-in case; restore it afterward. + if v, ok := os.LookupEnv("ENTIRE_TOKEN"); ok { + os.Unsetenv("ENTIRE_TOKEN") + // t.Setenv can't unset, and there's no t.Unsetenv, so restore manually. + t.Cleanup(func() { os.Setenv("ENTIRE_TOKEN", v) }) //nolint:usetesting // restoring a captured value; no t.Unsetenv equivalent + } + + t.Run("prints the env token verbatim", func(t *testing.T) { + token := makeTestJWT(t, `{"sub":"ci","aud":"https://core.us.entire.io"}`) + t.Setenv("ENTIRE_TOKEN", token) + + cmd := newAuthTokenCmd() + var out, errOut bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&errOut) + require.NoError(t, cmd.ExecuteContext(t.Context())) + require.Equal(t, token+"\n", out.String()) + require.Empty(t, errOut.String()) + }) + + t.Run("not logged in errors silently with a hint", func(t *testing.T) { + // Isolated empty config so there's no active context to resolve. + t.Setenv("ENTIRE_CONFIG_DIR", t.TempDir()) + + cmd := newAuthTokenCmd() + var out, errOut bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&errOut) + err := cmd.ExecuteContext(t.Context()) + + var silent *SilentError + require.ErrorAs(t, err, &silent) + require.Empty(t, out.String(), "stdout must stay clean for command substitution") + require.Contains(t, errOut.String(), "Not logged in") + }) +} From 6f7d464086107f380b6b3e88d00caea85988fdd0 Mon Sep 17 00:00:00 2001 From: paul <423357+toothbrush@users.noreply.github.com> Date: Thu, 25 Jun 2026 12:40:35 +0930 Subject: [PATCH 08/17] Make default cluster jurisdiction-aware is_default is per-jurisdiction (each of au/eu/us has its own default), so the single hardcoded "first default" was wrong. Resolve the caller's jurisdiction from /me and: - one-shot `repo mirror create ` (no cluster-host): pick the is_default cluster for that jurisdiction; - wizard: still list every cluster (so you can mirror into any), but pre-select only your jurisdiction's default instead of all three. Errors asking for an explicit [cluster-host] when the jurisdiction is unknown or has no default. Surfaces the jurisdiction in the wizard's "Signed in as" line. Co-Authored-By: Claude Opus 4.8 (1M context) Entire-Checkpoint: a03b61382aa9 --- cmd/entire/cli/auth.go | 4 ++ cmd/entire/cli/repo_mirror.go | 56 ++++++++++++++----- cmd/entire/cli/repo_mirror_create_wizard.go | 49 +++++++++------- .../cli/repo_mirror_create_wizard_test.go | 13 ++++- cmd/entire/cli/repo_mirror_test.go | 44 ++++++++++----- 5 files changed, 117 insertions(+), 49 deletions(-) diff --git a/cmd/entire/cli/auth.go b/cmd/entire/cli/auth.go index 1556ab33ac..0d2e9b2ff2 100644 --- a/cmd/entire/cli/auth.go +++ b/cmd/entire/cli/auth.go @@ -199,6 +199,9 @@ type authProfile struct { Email string Provider string ProviderUserID string + // Jurisdiction is the caller's home jurisdiction slug (e.g. "eu"), used to + // pick the default mirror cluster for that jurisdiction. May be empty. + Jurisdiction string } // profileFetcher fetches a user's profile via GET /me on coreURL, authenticated @@ -319,6 +322,7 @@ func defaultFetchProfile(ctx context.Context, coreURL, token string) (*authProfi ProviderUserID: me.Auth.ProviderUserId, } p.Handle, _ = me.Global.Handle.Get() + p.Jurisdiction, _ = me.Jurisdiction.Get() if reg, ok := me.Regional.Get(); ok { p.DisplayName, _ = reg.DisplayName.Get() p.Email, _ = reg.Email.Get() diff --git a/cmd/entire/cli/repo_mirror.go b/cmd/entire/cli/repo_mirror.go index da1d7d2ef2..25872df4d3 100644 --- a/cmd/entire/cli/repo_mirror.go +++ b/cmd/entire/cli/repo_mirror.go @@ -212,27 +212,55 @@ func resolveDefaultClusterHost(ctx context.Context, c *coreapi.Client) (string, if err != nil { return "", err } - return pickDefaultRegionHost(regions) + jurisdiction, err := callerJurisdiction(ctx, c) + if err != nil { + return "", err + } + return pickDefaultRegionHost(regions, jurisdiction) } -// pickDefaultRegionHost chooses the default cluster host from the catalog: the -// is-default cluster, or the sole cluster when there's exactly one. Empty or -// ambiguous catalogs error so the caller can tell the user to pass -// [cluster-host]. Pure, so the selection is unit-testable without a live core. -func pickDefaultRegionHost(regions []regionChoice) (string, error) { +// callerJurisdiction returns the active principal's home jurisdiction slug from +// GET /me (e.g. "eu"). May return "" if the server doesn't report one. +func callerJurisdiction(ctx context.Context, c *coreapi.Client) (string, error) { + me, err := c.GetMe(ctx) + if err != nil { + return "", renderCoreError(err) + } + j, _ := me.Jurisdiction.Get() + return j, nil +} + +// pickDefaultRegionHost chooses the default cluster host for the caller's +// jurisdiction. is_default is per-jurisdiction (each jurisdiction has one), so a +// known jurisdiction selects its default directly. With no jurisdiction it falls +// back to a lone cluster or a lone default; anything ambiguous errors so the +// caller can tell the user to pass [cluster-host]. Pure, so the selection is +// unit-testable without a live core. +func pickDefaultRegionHost(regions []regionChoice, jurisdiction string) (string, error) { + if len(regions) == 0 { + return "", errors.New("no clusters available to mirror into; pass [cluster-host] explicitly") + } + if jurisdiction != "" { + for _, r := range regions { + if r.isDefault && r.jurisdiction == jurisdiction { + return r.host, nil + } + } + return "", fmt.Errorf("no default cluster for your jurisdiction (%s); pass [cluster-host] explicitly", jurisdiction) + } + if len(regions) == 1 { + return regions[0].host, nil + } + var defaults []regionChoice for _, r := range regions { if r.isDefault { - return r.host, nil + defaults = append(defaults, r) } } - switch len(regions) { - case 0: - return "", errors.New("no clusters available to mirror into; pass [cluster-host] explicitly") - case 1: - return regions[0].host, nil - default: - return "", errors.New("no default cluster configured; pass [cluster-host] explicitly") + if len(defaults) == 1 { + return defaults[0].host, nil } + return "", errors.New("could not determine your jurisdiction's default cluster; pass [cluster-host] explicitly") } // mirrorCreateOutcome bundles the create response with the clone status diff --git a/cmd/entire/cli/repo_mirror_create_wizard.go b/cmd/entire/cli/repo_mirror_create_wizard.go index 8daca00dbf..955531c23a 100644 --- a/cmd/entire/cli/repo_mirror_create_wizard.go +++ b/cmd/entire/cli/repo_mirror_create_wizard.go @@ -136,13 +136,17 @@ func selectableAvailableRepos(avail []coreapi.AvailableMirror) []coreapi.Availab return out } -// clusterChoices maps regions to multi-select options (value = bare host) and -// the hosts that should start checked (the is-default regions). -func clusterChoices(regions []regionChoice) (opts []huh.Option[string], defaults []string) { +// clusterChoices maps regions to multi-select options (value = bare host), +// listing every cluster, and returns the host(s) that should start checked: +// the default cluster for the caller's jurisdiction. is_default is +// per-jurisdiction, so pre-selecting only the caller's avoids defaulting a repo +// into every jurisdiction. With no jurisdiction nothing is pre-checked (the user +// picks). All clusters stay selectable regardless. +func clusterChoices(regions []regionChoice, jurisdiction string) (opts []huh.Option[string], defaults []string) { opts = make([]huh.Option[string], 0, len(regions)) for _, r := range regions { opts = append(opts, huh.NewOption(regionLabel(r), r.host)) - if r.isDefault { + if jurisdiction != "" && r.isDefault && r.jurisdiction == jurisdiction { defaults = append(defaults, r.host) } } @@ -218,7 +222,8 @@ func runMirrorCreateWizard(cmd *cobra.Command, noWait bool, waitTimeout time.Dur auth.EnableInsecureHTTP() } - if err := ensureMirrorWizardAuth(ctx, errW, insecure); err != nil { + jurisdiction, err := ensureMirrorWizardAuth(ctx, errW, insecure) + if err != nil { return err } @@ -257,7 +262,7 @@ func runMirrorCreateWizard(cmd *cobra.Command, noWait bool, waitTimeout time.Dur if len(regions) == 0 { return errors.New("no regions available to mirror into") } - selectedRegions, err := pickRegions(outW, regions) + selectedRegions, err := pickRegions(outW, regions, jurisdiction) if err != nil || len(selectedRegions) == 0 { return err } @@ -272,31 +277,37 @@ func runMirrorCreateWizard(cmd *cobra.Command, noWait bool, waitTimeout time.Dur // ensureMirrorWizardAuth mirrors `entire auth status`: resolve the active // target (honouring ENTIRE_TOKEN), enforce TLS on the core we'll dial, and // validate the token with a /me probe so the wizard fails fast with a re-login -// hint rather than deep inside the first API call. -func ensureMirrorWizardAuth(ctx context.Context, errW io.Writer, insecure bool) error { +// hint rather than deep inside the first API call. Returns the caller's home +// jurisdiction (from /me, may be "") so the region picker can pre-select that +// jurisdiction's default cluster. +func ensureMirrorWizardAuth(ctx context.Context, errW io.Writer, insecure bool) (string, error) { target, err := resolveAuthStatusTarget(ctx, auth.Contexts, auth.RefreshedLoginToken) if err != nil { - return err + return "", err } if target.token == "" { fmt.Fprintln(errW, "Not logged in. Run 'entire login' to authenticate.") - return NewSilentError(errors.New("not logged in")) + return "", NewSilentError(errors.New("not logged in")) } if !insecure && target.coreURL != "" { if err := api.RequireSecureURL(target.coreURL); err != nil { - return fmt.Errorf("login server URL check: %w", err) + return "", fmt.Errorf("login server URL check: %w", err) } } profile, err := defaultFetchProfile(ctx, target.coreURL, target.token) if err != nil { if isKeychainTokenRejected(err) { fmt.Fprintf(errW, "Login for %s is no longer valid. Run 'entire login' to re-authenticate.\n", target.coreURL) - return NewSilentError(errors.New("login no longer valid")) + return "", NewSilentError(errors.New("login no longer valid")) } - return fmt.Errorf("validate auth: %w", err) + return "", fmt.Errorf("validate auth: %w", err) } - fmt.Fprintf(errW, "Signed in as %s via %s\n", profile.Handle, target.coreURL) - return nil + if profile.Jurisdiction != "" { + fmt.Fprintf(errW, "Signed in as %s (%s) via %s\n", profile.Handle, profile.Jurisdiction, target.coreURL) + } else { + fmt.Fprintf(errW, "Signed in as %s via %s\n", profile.Handle, target.coreURL) + } + return profile.Jurisdiction, nil } // pickRepos runs the repo multi-select and returns the chosen available @@ -339,10 +350,10 @@ func pickRepos(w io.Writer, repos []coreapi.AvailableMirror) ([]coreapi.Availabl return chosen, nil } -// pickRegions runs the region multi-select, pre-selecting the default -// region(s). A clean cancel returns (nil, nil). -func pickRegions(w io.Writer, regions []regionChoice) ([]regionChoice, error) { - opts, defaults := clusterChoices(regions) +// pickRegions runs the region multi-select, pre-selecting the default cluster +// for the caller's jurisdiction. A clean cancel returns (nil, nil). +func pickRegions(w io.Writer, regions []regionChoice, jurisdiction string) ([]regionChoice, error) { + opts, defaults := clusterChoices(regions, jurisdiction) regionByHost := make(map[string]regionChoice, len(regions)) for _, r := range regions { regionByHost[r.host] = r diff --git a/cmd/entire/cli/repo_mirror_create_wizard_test.go b/cmd/entire/cli/repo_mirror_create_wizard_test.go index 28e26c0e49..cb7b0f37f1 100644 --- a/cmd/entire/cli/repo_mirror_create_wizard_test.go +++ b/cmd/entire/cli/repo_mirror_create_wizard_test.go @@ -66,19 +66,26 @@ func TestHostFromPublicURL(t *testing.T) { func TestClusterChoices(t *testing.T) { t.Parallel() regions := []regionChoice{ + // Both us-east and eu-west are defaults — for their own jurisdictions. {slug: "us-east", jurisdiction: "us", host: "aws-us-east-2.entire.io", isDefault: true}, - {slug: "eu-west", jurisdiction: "eu", host: "eu-west-1.entire.io"}, + {slug: "eu-west", jurisdiction: "eu", host: "eu-west-1.entire.io", isDefault: true}, {host: "bare.entire.io"}, // no slug/jurisdiction } - opts, defaults := clusterChoices(regions) + // Caller is in "eu": every cluster is listed, but only the eu default + // pre-selects (is_default is per-jurisdiction, so the us default must not). + opts, defaults := clusterChoices(regions, "eu") require.Len(t, opts, 3) require.Equal(t, "us-east (us)", opts[0].Key) require.Equal(t, "aws-us-east-2.entire.io", opts[0].Value) require.Equal(t, "eu-west (eu)", opts[1].Key) require.Equal(t, "bare.entire.io", opts[2].Key) // falls back to host - require.Equal(t, []string{"aws-us-east-2.entire.io"}, defaults) + require.Equal(t, []string{"eu-west-1.entire.io"}, defaults) + + // Unknown jurisdiction: all still listed, nothing pre-selected. + _, noneDefault := clusterChoices(regions, "") + require.Empty(t, noneDefault) } func TestRegionLabel(t *testing.T) { diff --git a/cmd/entire/cli/repo_mirror_test.go b/cmd/entire/cli/repo_mirror_test.go index 9a7038e5a9..7bbbb757a9 100644 --- a/cmd/entire/cli/repo_mirror_test.go +++ b/cmd/entire/cli/repo_mirror_test.go @@ -455,34 +455,52 @@ func TestAvailableMirrorRow(t *testing.T) { func TestPickDefaultRegionHost(t *testing.T) { t.Parallel() - t.Run("prefers the is-default cluster", func(t *testing.T) { + // Three per-jurisdiction defaults, like prod. + threeDefaults := []regionChoice{ + {host: "aws-ap-southeast-2.entire.io", jurisdiction: "au", isDefault: true}, + {host: "aws-eu-central-1.entire.io", jurisdiction: "eu", isDefault: true}, + {host: "aws-us-east-2.entire.io", jurisdiction: "us", isDefault: true}, + } + + t.Run("picks the default for the caller's jurisdiction", func(t *testing.T) { t.Parallel() - host, err := pickDefaultRegionHost([]regionChoice{ - {host: "eu-west-1.entire.io"}, - {host: "aws-us-east-2.entire.io", isDefault: true}, - }) + host, err := pickDefaultRegionHost(threeDefaults, "eu") require.NoError(t, err) - require.Equal(t, "aws-us-east-2.entire.io", host) + require.Equal(t, "aws-eu-central-1.entire.io", host) + }) + + t.Run("jurisdiction with no default errors", func(t *testing.T) { + t.Parallel() + _, err := pickDefaultRegionHost(threeDefaults, "antarctica") + require.Error(t, err) }) - t.Run("sole cluster is the default", func(t *testing.T) { + t.Run("unknown jurisdiction, sole cluster is the default", func(t *testing.T) { t.Parallel() - host, err := pickDefaultRegionHost([]regionChoice{{host: "only.entire.io"}}) + host, err := pickDefaultRegionHost([]regionChoice{{host: "only.entire.io"}}, "") require.NoError(t, err) require.Equal(t, "only.entire.io", host) }) + t.Run("unknown jurisdiction, lone default wins", func(t *testing.T) { + t.Parallel() + host, err := pickDefaultRegionHost([]regionChoice{ + {host: "a.entire.io"}, + {host: "b.entire.io", isDefault: true}, + }, "") + require.NoError(t, err) + require.Equal(t, "b.entire.io", host) + }) + t.Run("empty catalog errors", func(t *testing.T) { t.Parallel() - _, err := pickDefaultRegionHost(nil) + _, err := pickDefaultRegionHost(nil, "eu") require.Error(t, err) }) - t.Run("ambiguous (multiple, none default) errors", func(t *testing.T) { + t.Run("unknown jurisdiction with multiple defaults errors", func(t *testing.T) { t.Parallel() - _, err := pickDefaultRegionHost([]regionChoice{ - {host: "a.entire.io"}, {host: "b.entire.io"}, - }) + _, err := pickDefaultRegionHost(threeDefaults, "") require.Error(t, err) }) } From 68e81f3834c7668d75851e3947ed5c324f5cda41 Mon Sep 17 00:00:00 2001 From: paul <423357+toothbrush@users.noreply.github.com> Date: Thu, 25 Jun 2026 12:45:16 +0930 Subject: [PATCH 09/17] Limit cluster guessing to the interactive wizard Revert the one-shot `repo mirror create ` to the fixed defaultClusterHost when [cluster-host] is omitted; catalog/jurisdiction-based cluster guessing is now confined to the no-args interactive wizard, so non-interactive invocations (and scripts) keep stable, predictable defaults. remove/collaborators were already on the fixed default and are unchanged. Drops the one-shot-only resolveDefaultClusterHost/callerJurisdiction/ pickDefaultRegionHost helpers; the wizard keeps its jurisdiction-aware region pre-selection. Co-Authored-By: Claude Opus 4.8 (1M context) Entire-Checkpoint: a03599d356ff --- cmd/entire/cli/repo_mirror.go | 98 ++++-------------------------- cmd/entire/cli/repo_mirror_test.go | 53 ---------------- 2 files changed, 11 insertions(+), 140 deletions(-) diff --git a/cmd/entire/cli/repo_mirror.go b/cmd/entire/cli/repo_mirror.go index 25872df4d3..d5bd339db6 100644 --- a/cmd/entire/cli/repo_mirror.go +++ b/cmd/entire/cli/repo_mirror.go @@ -145,8 +145,9 @@ func newRepoMirrorCreateCmd() *cobra.Command { "the target cluster, then waits for the initial GitHub→EntireDB clone " + "to finish so `git clone` works on return. Pass --no-wait to return " + "as soon as the placement is registered. Idempotent on " + - "(upstream, cluster). The cluster-host defaults to your catalog's " + - "default cluster when omitted.", + "(upstream, cluster). The cluster-host defaults to " + + defaultClusterHost + " when omitted (the interactive wizard, with " + + "no args, instead lets you pick clusters).", Example: " entire repo mirror create\n" + " entire repo mirror create github.com/octocat/hello-world\n" + " entire repo mirror create github.com/octocat/hello-world aws-us-east-2.entire.io", @@ -160,29 +161,14 @@ func newRepoMirrorCreateCmd() *cobra.Command { cmd.SilenceUsage = true return fmt.Errorf("invalid : %w", err) } - var clusterHost string - if len(args) > 1 { - clusterHost = args[1] - if err := validateClusterHost(clusterHost); err != nil { - cmd.SilenceUsage = true - return fmt.Errorf("invalid [cluster-host]: %w", err) - } - } else { - // No cluster given: pick the catalog's default cluster (the same - // GET /api/v1/clusters source the wizard uses), resolved via the - // active context. runCore owns the active-client preamble - // (silence-usage, --insecure-http-auth, error mapping). - if err := runCore(cmd, func(ctx context.Context, c *coreapi.Client) error { - h, rerr := resolveDefaultClusterHost(ctx, c) - if rerr != nil { - return rerr - } - clusterHost = h - fmt.Fprintf(cmd.ErrOrStderr(), "Using default cluster %s\n", clusterHost) - return nil - }); err != nil { - return err - } + // The non-interactive one-shot keeps a fixed default cluster + // (defaultClusterHost) when [cluster-host] is omitted — catalog-based + // cluster guessing is intentionally limited to the interactive + // wizard (the no-args path above), so scripts get stable behavior. + clusterHost := clusterArg(args) + if err := validateClusterHost(clusterHost); err != nil { + cmd.SilenceUsage = true + return fmt.Errorf("invalid [cluster-host]: %w", err) } return runCoreForCluster(cmd, clusterHost, func(ctx context.Context, c *coreapi.Client) error { errW := cmd.ErrOrStderr() @@ -201,68 +187,6 @@ func newRepoMirrorCreateCmd() *cobra.Command { return cmd } -// resolveDefaultClusterHost picks the cluster a `repo mirror create -// ` targets when [cluster-host] is omitted, from the control plane's -// cluster catalog (the same GET /api/v1/clusters the wizard uses): the cluster -// flagged is_default, or the sole cluster when there is exactly one. It errors -// when the catalog is empty or has no clear default so the user knows to pass -// [cluster-host] explicitly, rather than silently guessing. -func resolveDefaultClusterHost(ctx context.Context, c *coreapi.Client) (string, error) { - regions, err := availableRegions(ctx, c) - if err != nil { - return "", err - } - jurisdiction, err := callerJurisdiction(ctx, c) - if err != nil { - return "", err - } - return pickDefaultRegionHost(regions, jurisdiction) -} - -// callerJurisdiction returns the active principal's home jurisdiction slug from -// GET /me (e.g. "eu"). May return "" if the server doesn't report one. -func callerJurisdiction(ctx context.Context, c *coreapi.Client) (string, error) { - me, err := c.GetMe(ctx) - if err != nil { - return "", renderCoreError(err) - } - j, _ := me.Jurisdiction.Get() - return j, nil -} - -// pickDefaultRegionHost chooses the default cluster host for the caller's -// jurisdiction. is_default is per-jurisdiction (each jurisdiction has one), so a -// known jurisdiction selects its default directly. With no jurisdiction it falls -// back to a lone cluster or a lone default; anything ambiguous errors so the -// caller can tell the user to pass [cluster-host]. Pure, so the selection is -// unit-testable without a live core. -func pickDefaultRegionHost(regions []regionChoice, jurisdiction string) (string, error) { - if len(regions) == 0 { - return "", errors.New("no clusters available to mirror into; pass [cluster-host] explicitly") - } - if jurisdiction != "" { - for _, r := range regions { - if r.isDefault && r.jurisdiction == jurisdiction { - return r.host, nil - } - } - return "", fmt.Errorf("no default cluster for your jurisdiction (%s); pass [cluster-host] explicitly", jurisdiction) - } - if len(regions) == 1 { - return regions[0].host, nil - } - var defaults []regionChoice - for _, r := range regions { - if r.isDefault { - defaults = append(defaults, r) - } - } - if len(defaults) == 1 { - return defaults[0].host, nil - } - return "", errors.New("could not determine your jurisdiction's default cluster; pass [cluster-host] explicitly") -} - // mirrorCreateOutcome bundles the create response with the clone status // observed while waiting. polled is false for --no-wait and for empty upstreams, // where there is nothing to await; in those cases status is unset. diff --git a/cmd/entire/cli/repo_mirror_test.go b/cmd/entire/cli/repo_mirror_test.go index 7bbbb757a9..c2044de56c 100644 --- a/cmd/entire/cli/repo_mirror_test.go +++ b/cmd/entire/cli/repo_mirror_test.go @@ -452,59 +452,6 @@ func TestAvailableMirrorRow(t *testing.T) { } } -func TestPickDefaultRegionHost(t *testing.T) { - t.Parallel() - - // Three per-jurisdiction defaults, like prod. - threeDefaults := []regionChoice{ - {host: "aws-ap-southeast-2.entire.io", jurisdiction: "au", isDefault: true}, - {host: "aws-eu-central-1.entire.io", jurisdiction: "eu", isDefault: true}, - {host: "aws-us-east-2.entire.io", jurisdiction: "us", isDefault: true}, - } - - t.Run("picks the default for the caller's jurisdiction", func(t *testing.T) { - t.Parallel() - host, err := pickDefaultRegionHost(threeDefaults, "eu") - require.NoError(t, err) - require.Equal(t, "aws-eu-central-1.entire.io", host) - }) - - t.Run("jurisdiction with no default errors", func(t *testing.T) { - t.Parallel() - _, err := pickDefaultRegionHost(threeDefaults, "antarctica") - require.Error(t, err) - }) - - t.Run("unknown jurisdiction, sole cluster is the default", func(t *testing.T) { - t.Parallel() - host, err := pickDefaultRegionHost([]regionChoice{{host: "only.entire.io"}}, "") - require.NoError(t, err) - require.Equal(t, "only.entire.io", host) - }) - - t.Run("unknown jurisdiction, lone default wins", func(t *testing.T) { - t.Parallel() - host, err := pickDefaultRegionHost([]regionChoice{ - {host: "a.entire.io"}, - {host: "b.entire.io", isDefault: true}, - }, "") - require.NoError(t, err) - require.Equal(t, "b.entire.io", host) - }) - - t.Run("empty catalog errors", func(t *testing.T) { - t.Parallel() - _, err := pickDefaultRegionHost(nil, "eu") - require.Error(t, err) - }) - - t.Run("unknown jurisdiction with multiple defaults errors", func(t *testing.T) { - t.Parallel() - _, err := pickDefaultRegionHost(threeDefaults, "") - require.Error(t, err) - }) -} - func TestClusterArg(t *testing.T) { t.Parallel() if got := clusterArg([]string{"github.com/o/r", "eu-west-1.entire.io"}); got != "eu-west-1.entire.io" { From 37fa329a577b6d068192c87ba158d948a2104582 Mon Sep 17 00:00:00 2001 From: paul <423357+toothbrush@users.noreply.github.com> Date: Thu, 25 Jun 2026 12:50:51 +0930 Subject: [PATCH 10/17] Order wizard region picker by caller's jurisdiction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The region multi-select already lists every cluster, but on a short terminal huh's scrollable viewport shows only the top rows — so the pre-checked default (the caller's jurisdiction) could sit below the fold, making it look like one unrelated option. List the caller's-jurisdiction clusters first so the visible top row is the relevant, pre-selected default; trim the description to reclaim a row. Other jurisdictions remain selectable by scrolling. Co-Authored-By: Claude Opus 4.8 (1M context) Entire-Checkpoint: 27ad338c331d --- cmd/entire/cli/repo_mirror_create_wizard.go | 17 +++++++++++--- .../cli/repo_mirror_create_wizard_test.go | 22 ++++++++++++------- 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/cmd/entire/cli/repo_mirror_create_wizard.go b/cmd/entire/cli/repo_mirror_create_wizard.go index 955531c23a..372ecc9891 100644 --- a/cmd/entire/cli/repo_mirror_create_wizard.go +++ b/cmd/entire/cli/repo_mirror_create_wizard.go @@ -142,9 +142,20 @@ func selectableAvailableRepos(avail []coreapi.AvailableMirror) []coreapi.Availab // per-jurisdiction, so pre-selecting only the caller's avoids defaulting a repo // into every jurisdiction. With no jurisdiction nothing is pre-checked (the user // picks). All clusters stay selectable regardless. +// +// The caller's-jurisdiction clusters are listed first so that on a short +// terminal — where huh's option viewport shows only the top rows — the visible, +// pre-checked default is the relevant one, not some other jurisdiction's. func clusterChoices(regions []regionChoice, jurisdiction string) (opts []huh.Option[string], defaults []string) { - opts = make([]huh.Option[string], 0, len(regions)) - for _, r := range regions { + ordered := make([]regionChoice, len(regions)) + copy(ordered, regions) + if jurisdiction != "" { + sort.SliceStable(ordered, func(i, j int) bool { + return ordered[i].jurisdiction == jurisdiction && ordered[j].jurisdiction != jurisdiction + }) + } + opts = make([]huh.Option[string], 0, len(ordered)) + for _, r := range ordered { opts = append(opts, huh.NewOption(regionLabel(r), r.host)) if jurisdiction != "" && r.isDefault && r.jurisdiction == jurisdiction { defaults = append(defaults, r.host) @@ -365,7 +376,7 @@ func pickRegions(w io.Writer, regions []regionChoice, jurisdiction string) ([]re huh.NewGroup( huh.NewMultiSelect[string](). Title("Select regions to mirror into"). - Description("Each selected repo is mirrored into every selected region. Space to select, enter to confirm."). + Description("Each repo is mirrored into every selected region."). Options(opts...). Validate(func(s []string) error { if len(s) == 0 { diff --git a/cmd/entire/cli/repo_mirror_create_wizard_test.go b/cmd/entire/cli/repo_mirror_create_wizard_test.go index cb7b0f37f1..229ce57894 100644 --- a/cmd/entire/cli/repo_mirror_create_wizard_test.go +++ b/cmd/entire/cli/repo_mirror_create_wizard_test.go @@ -72,20 +72,26 @@ func TestClusterChoices(t *testing.T) { {host: "bare.entire.io"}, // no slug/jurisdiction } - // Caller is in "eu": every cluster is listed, but only the eu default - // pre-selects (is_default is per-jurisdiction, so the us default must not). + // Caller is in "eu": every cluster is listed, the eu cluster is ordered + // first (so it's visible+checked on a short terminal), and only the eu + // default pre-selects (is_default is per-jurisdiction, so us must not). opts, defaults := clusterChoices(regions, "eu") require.Len(t, opts, 3) - require.Equal(t, "us-east (us)", opts[0].Key) - require.Equal(t, "aws-us-east-2.entire.io", opts[0].Value) - require.Equal(t, "eu-west (eu)", opts[1].Key) - require.Equal(t, "bare.entire.io", opts[2].Key) // falls back to host + require.Equal(t, "eu-west (eu)", opts[0].Key, "caller's jurisdiction listed first") + require.Equal(t, "eu-west-1.entire.io", opts[0].Value) require.Equal(t, []string{"eu-west-1.entire.io"}, defaults) + // The other jurisdictions are still present, in their original relative order. + var keys []string + for _, o := range opts { + keys = append(keys, o.Key) + } + require.ElementsMatch(t, []string{"us-east (us)", "eu-west (eu)", "bare.entire.io"}, keys) - // Unknown jurisdiction: all still listed, nothing pre-selected. - _, noneDefault := clusterChoices(regions, "") + // Unknown jurisdiction: all still listed, original order, nothing pre-selected. + noOpts, noneDefault := clusterChoices(regions, "") require.Empty(t, noneDefault) + require.Equal(t, "us-east (us)", noOpts[0].Key) } func TestRegionLabel(t *testing.T) { From 65d2deea0b545d1a3b7dcaf4a7d0f8e5ef0f029d Mon Sep 17 00:00:00 2001 From: paul <423357+toothbrush@users.noreply.github.com> Date: Thu, 25 Jun 2026 12:55:57 +0930 Subject: [PATCH 11/17] Fix region picker clamped to one visible option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit huh sizes an unset multi-select height to (rendered option lines − title/ description rows), so a short list (3 regions) collapsed to ~1 visible row while a long repo list looked fine. Set an explicit Height = option count + header slack on both pickers so every option is visible (still scrolls past the terminal). Region picker is no longer stuck showing one option at a time. Co-Authored-By: Claude Opus 4.8 (1M context) Entire-Checkpoint: 6e993f6cd3aa --- cmd/entire/cli/repo_mirror_create_wizard.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/cmd/entire/cli/repo_mirror_create_wizard.go b/cmd/entire/cli/repo_mirror_create_wizard.go index 372ecc9891..df9e6875e6 100644 --- a/cmd/entire/cli/repo_mirror_create_wizard.go +++ b/cmd/entire/cli/repo_mirror_create_wizard.go @@ -136,6 +136,18 @@ func selectableAvailableRepos(avail []coreapi.AvailableMirror) []coreapi.Availab return out } +// multiSelectHeight returns an explicit huh multi-select Height that keeps every +// option visible. huh auto-sizes an unset height to (rendered option lines − +// title/description rows), which collapses to ~1 visible row for short lists +// (e.g. 3 regions vs. a long repo list) — the cause of the region picker +// appearing clamped to one option. We set it to the option count plus slack for +// the title + (possibly wrapped) description so the whole list shows; huh still +// scrolls if the list outgrows the terminal. +func multiSelectHeight(n int) int { + const headerSlack = 3 // title (1) + description (1–2 when wrapped) + return n + headerSlack +} + // clusterChoices maps regions to multi-select options (value = bare host), // listing every cluster, and returns the host(s) that should start checked: // the default cluster for the caller's jurisdiction. is_default is @@ -339,6 +351,7 @@ func pickRepos(w io.Writer, repos []coreapi.AvailableMirror) ([]coreapi.Availabl Title("Select repos to mirror"). Description("Space to select, enter to confirm."). Options(options...). + Height(multiSelectHeight(len(options))). Validate(func(s []string) error { if len(s) == 0 { return errors.New("select at least one repo") @@ -378,6 +391,7 @@ func pickRegions(w io.Writer, regions []regionChoice, jurisdiction string) ([]re Title("Select regions to mirror into"). Description("Each repo is mirrored into every selected region."). Options(opts...). + Height(multiSelectHeight(len(opts))). Validate(func(s []string) error { if len(s) == 0 { return errors.New("select at least one region") From 0dbc9eb2388b985d58561d6907032a17cc644014 Mon Sep 17 00:00:00 2001 From: paul <423357+toothbrush@users.noreply.github.com> Date: Thu, 25 Jun 2026 13:01:32 +0930 Subject: [PATCH 12/17] Show Docker-style per-mirror progress in the wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the single "Creating N mirror(s)…" spinner with one live line per (repo, region), each updating independently as its CreateMirror + clone poll advance (processing → ready), then the existing summary table. Lines read "owner/repo @ ". A per-poll status callback now flows through awaitMirrorReady/createAndAwaitMirror (one-shot passes nil and keeps its single spinner). Non-TTY degrades to one printed line per mirror as it finalizes. Co-Authored-By: Claude Opus 4.8 (1M context) Entire-Checkpoint: 18dbd0a73461 --- cmd/entire/cli/repo_mirror.go | 8 +- cmd/entire/cli/repo_mirror_create_wizard.go | 153 +++++++++++++++++- .../cli/repo_mirror_create_wizard_test.go | 21 +++ cmd/entire/cli/repo_mirror_probe.go | 8 +- cmd/entire/cli/repo_mirror_test.go | 10 +- 5 files changed, 184 insertions(+), 16 deletions(-) diff --git a/cmd/entire/cli/repo_mirror.go b/cmd/entire/cli/repo_mirror.go index d5bd339db6..1702c8e1ee 100644 --- a/cmd/entire/cli/repo_mirror.go +++ b/cmd/entire/cli/repo_mirror.go @@ -173,7 +173,9 @@ func newRepoMirrorCreateCmd() *cobra.Command { return runCoreForCluster(cmd, clusterHost, func(ctx context.Context, c *coreapi.Client) error { errW := cmd.ErrOrStderr() stop := startSpinner(errW, fmt.Sprintf("Cloning %s/%s into %s", owner, repo, clusterHost)) - outcome, err := createAndAwaitMirror(ctx, c, owner, repo, clusterHost, noWait, waitTimeout) + // nil onStatus: the one-shot's single spinner shows liveness; the + // per-mirror progress lines are the wizard's concern. + outcome, err := createAndAwaitMirror(ctx, c, owner, repo, clusterHost, noWait, waitTimeout, nil) // Only a confirmed-ready clone earns the ✓; everything else // (empty, --no-wait, suspended, failed, timeout) erases the line // and lets reportOneShotMirror print the specific outcome. @@ -204,7 +206,7 @@ type mirrorCreateOutcome struct { // status. The returned error is the create error (when outcome.created is nil) // or the wait error — a status sentinel (errMirrorCloneFailed / // errMirrorSuspended) or a timeout; callers read outcome.status for the state. -func createAndAwaitMirror(ctx context.Context, c *coreapi.Client, owner, repo, clusterHost string, noWait bool, timeout time.Duration) (mirrorCreateOutcome, error) { +func createAndAwaitMirror(ctx context.Context, c *coreapi.Client, owner, repo, clusterHost string, noWait bool, timeout time.Duration, onStatus func(coreapi.MirrorStatus)) (mirrorCreateOutcome, error) { created, err := c.CreateMirror(ctx, &coreapi.CreateMirrorInputBody{ Provider: coreapi.CreateMirrorInputBodyProviderGithub, Owner: owner, @@ -218,7 +220,7 @@ func createAndAwaitMirror(ctx context.Context, c *coreapi.Client, owner, repo, c if noWait || created.Empty { return outcome, nil } - status, werr := awaitMirrorReady(ctx, c, created.MirrorId, timeout) + status, werr := awaitMirrorReady(ctx, c, created.MirrorId, timeout, onStatus) outcome.status = status outcome.polled = true return outcome, werr diff --git a/cmd/entire/cli/repo_mirror_create_wizard.go b/cmd/entire/cli/repo_mirror_create_wizard.go index df9e6875e6..24782bc4b7 100644 --- a/cmd/entire/cli/repo_mirror_create_wizard.go +++ b/cmd/entire/cli/repo_mirror_create_wizard.go @@ -8,6 +8,7 @@ import ( "net/url" "sort" "strings" + "sync" "time" "charm.land/huh/v2" @@ -16,6 +17,7 @@ import ( "github.com/entireio/cli/cmd/entire/cli/api" "github.com/entireio/cli/cmd/entire/cli/auth" + "github.com/entireio/cli/cmd/entire/cli/interactive" "github.com/entireio/cli/internal/coreapi" ) @@ -438,13 +440,22 @@ func createMirrors(ctx context.Context, errW io.Writer, targets []mirrorTarget, } } - stop := startSpinner(errW, fmt.Sprintf("Creating %d mirror(s)…", len(targets))) + // Docker-pull-style live progress: one line per (repo, region), each + // updating independently as its CreateMirror + clone poll advance. + labels := make([]string, len(targets)) + for i, t := range targets { + labels[i] = t.owner + "/" + t.repo + " @ " + t.region.host + } + prog := newMirrorProgress(errW, labels) + prog.start() + results := make([]mirrorResult, len(targets)) g := new(errgroup.Group) g.SetLimit(mirrorCreateConcurrency) for i, t := range targets { g.Go(func() error { - results[i] = createOneMirror(ctx, t, clientByHost[t.region.host], clientErrByHost[t.region.host], noWait, waitTimeout) + results[i] = createOneMirror(ctx, t, clientByHost[t.region.host], clientErrByHost[t.region.host], noWait, waitTimeout, + func(status string, final, ok bool) { prog.set(i, status, final, ok) }) return nil }) } @@ -454,26 +465,35 @@ func createMirrors(ctx context.Context, errW io.Writer, targets []mirrorTarget, if err := g.Wait(); err != nil { fmt.Fprintf(errW, "mirror creation: %v\n", err) } - stop(true) + prog.stop() return results } // createOneMirror registers a single (repo, region) mirror and, unless noWait // or the upstream is empty, waits for its initial clone. It never returns an // error: every outcome is folded into the mirrorResult so a single failure -// can't sink the batch. -func createOneMirror(ctx context.Context, t mirrorTarget, c *coreapi.Client, clientErr error, noWait bool, waitTimeout time.Duration) mirrorResult { +// can't sink the batch. report (may be nil) is called as the mirror moves +// through its phases so the caller can render live progress; the final call has +// final=true and ok set to whether it succeeded. +func createOneMirror(ctx context.Context, t mirrorTarget, c *coreapi.Client, clientErr error, noWait bool, waitTimeout time.Duration, report func(status string, final, ok bool)) mirrorResult { + if report == nil { + report = func(string, bool, bool) {} + } res := mirrorResult{owner: t.owner, repo: t.repo, regionLabel: regionLabel(t.region)} if clientErr != nil { res.status, res.err = mirrorStatusError, clientErr + report(mirrorStatusError, true, false) return res } + report("creating", false, false) // Same create-then-wait path as the one-shot `repo mirror create ` // (createAndAwaitMirror), so both report identical lifecycle states. The - // poll is silent; the wizard's aggregate spinner shows liveness. - outcome, err := createAndAwaitMirror(ctx, c, t.owner, t.repo, t.region.host, noWait, waitTimeout) + // per-poll status drives this mirror's progress line. + outcome, err := createAndAwaitMirror(ctx, c, t.owner, t.repo, t.region.host, noWait, waitTimeout, + func(s coreapi.MirrorStatus) { report(string(s), false, false) }) if outcome.created == nil { res.status, res.err = mirrorStatusError, renderCoreError(err) + report(mirrorStatusError, true, false) return res } res.cloneURL = outcome.created.MirrorUrl @@ -484,6 +504,7 @@ func createOneMirror(ctx context.Context, t mirrorTarget, c *coreapi.Client, cli } else { res.status = mirrorStatusRegistered } + report(res.status, true, true) return res } @@ -508,9 +529,127 @@ func createOneMirror(ctx context.Context, t mirrorTarget, c *coreapi.Client, cli default: nonTerminal() } + report(res.status, true, res.err == nil) return res } +// mirrorProgress renders a Docker-pull-style live list: one line per mirror, +// each showing its label and a status that updates independently — a spinner +// while in flight, ✓/✗ once terminal. On a non-terminal writer (pipes, tests) +// it degrades to one printed line per mirror as each reaches a terminal state. +type mirrorProgress struct { + w io.Writer + tty bool + labelW int + mu sync.Mutex + lines []mirrorProgressLine + frame int + painted bool + done chan struct{} + stopped chan struct{} +} + +type mirrorProgressLine struct { + label string + status string + final bool + ok bool + printed bool // non-tty: terminal line already emitted +} + +func newMirrorProgress(w io.Writer, labels []string) *mirrorProgress { + lines := make([]mirrorProgressLine, len(labels)) + labelW := 0 + for i, l := range labels { + lines[i] = mirrorProgressLine{label: l, status: "queued"} + if n := len(l); n > labelW { + labelW = n + } + } + return &mirrorProgress{w: w, tty: interactive.IsTerminalWriter(w), labelW: labelW, lines: lines} +} + +// start paints the initial block and, on a TTY, begins animating the spinner. +func (p *mirrorProgress) start() { + if !p.tty { + return + } + p.done = make(chan struct{}) + p.stopped = make(chan struct{}) + p.mu.Lock() + p.renderLocked() + p.mu.Unlock() + go func() { + defer close(p.stopped) + ticker := time.NewTicker(spinnerInterval) + defer ticker.Stop() + for { + select { + case <-p.done: + return + case <-ticker.C: + p.mu.Lock() + p.frame++ + p.renderLocked() + p.mu.Unlock() + } + } + }() +} + +// set updates one mirror's line. On a TTY it repaints immediately; otherwise it +// prints a single line the first time the mirror reaches a terminal state. +func (p *mirrorProgress) set(i int, status string, final, ok bool) { + p.mu.Lock() + defer p.mu.Unlock() + p.lines[i].status = status + p.lines[i].final = final + p.lines[i].ok = ok + switch { + case p.tty: + p.renderLocked() + case final && !p.lines[i].printed: + p.lines[i].printed = true + fmt.Fprintf(p.w, "%s %s %s\n", terminalIcon(ok), p.lines[i].label, status) + } +} + +// stop ends the animation and leaves the final state painted. +func (p *mirrorProgress) stop() { + if !p.tty { + return + } + close(p.done) + <-p.stopped + p.mu.Lock() + p.renderLocked() + p.mu.Unlock() +} + +// renderLocked repaints the whole block in place. Caller holds p.mu. +func (p *mirrorProgress) renderLocked() { + if p.painted { + fmt.Fprintf(p.w, "\033[%dA", len(p.lines)) // move up to the block's top + } + p.painted = true + for _, ln := range p.lines { + var icon string + if ln.final { + icon = terminalIcon(ln.ok) + } else { + icon = spinnerFrames[p.frame%len(spinnerFrames)] + } + fmt.Fprintf(p.w, "\r\033[K%-*s %s %s\n", p.labelW, ln.label, icon, ln.status) + } +} + +func terminalIcon(ok bool) string { + if ok { + return "✓" + } + return "✗" +} + // reportMirrorResults renders the results table, a copy-pasteable git-clone // block for the ready mirrors, and per-failure detail. It returns a // SilentError (so the table isn't reprinted) when any mirror failed, giving the diff --git a/cmd/entire/cli/repo_mirror_create_wizard_test.go b/cmd/entire/cli/repo_mirror_create_wizard_test.go index 229ce57894..201f7a302c 100644 --- a/cmd/entire/cli/repo_mirror_create_wizard_test.go +++ b/cmd/entire/cli/repo_mirror_create_wizard_test.go @@ -1,6 +1,7 @@ package cli import ( + "bytes" "testing" "github.com/stretchr/testify/require" @@ -135,6 +136,26 @@ func TestMirrorCreateResultRow(t *testing.T) { ) } +func TestMirrorProgress_NonTTY(t *testing.T) { + t.Parallel() + // A bytes.Buffer is non-interactive, so the progress degrades to one printed + // line per mirror as it reaches a terminal state — no cursor escapes, and + // non-final updates print nothing. + var buf bytes.Buffer + p := newMirrorProgress(&buf, []string{"a/x @ aws-eu-central-1.entire.io", "b/y @ aws-us-east-2.entire.io"}) + p.start() + p.set(0, "processing", false, false) // in-flight: prints nothing + require.Empty(t, buf.String()) + p.set(0, "ready", true, true) + p.set(1, "failed", true, false) + p.stop() + + out := buf.String() + require.Contains(t, out, "✓ a/x @ aws-eu-central-1.entire.io ready") + require.Contains(t, out, "✗ b/y @ aws-us-east-2.entire.io failed") + require.NotContains(t, out, "\033[", "non-tty output must not emit cursor escapes") +} + func TestClustersToRegions(t *testing.T) { t.Parallel() in := []coreapi.Cluster{ diff --git a/cmd/entire/cli/repo_mirror_probe.go b/cmd/entire/cli/repo_mirror_probe.go index 494a215176..da1c238a92 100644 --- a/cmd/entire/cli/repo_mirror_probe.go +++ b/cmd/entire/cli/repo_mirror_probe.go @@ -95,7 +95,10 @@ type mirrorStatusGetter interface { // info/refs probe: the control plane now reports clone readiness directly via // Mirror.status, so a single authenticated control-plane call per tick suffices // — no repo-scoped token exchange or data-plane round trip. -func awaitMirrorReady(ctx context.Context, c mirrorStatusGetter, mirrorID string, timeout time.Duration) (coreapi.MirrorStatus, error) { +// +// onStatus (may be nil) is invoked with each observed status so callers can show +// live per-mirror progress (e.g. the wizard's Docker-style line list). +func awaitMirrorReady(ctx context.Context, c mirrorStatusGetter, mirrorID string, timeout time.Duration, onStatus func(coreapi.MirrorStatus)) (coreapi.MirrorStatus, error) { if timeout > 0 { var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, timeout) @@ -115,6 +118,9 @@ func awaitMirrorReady(ctx context.Context, c mirrorStatusGetter, mirrorID string } if s, ok := m.Status.Get(); ok { last = s + if onStatus != nil { + onStatus(s) + } switch s { case coreapi.MirrorStatusReady: return s, nil diff --git a/cmd/entire/cli/repo_mirror_test.go b/cmd/entire/cli/repo_mirror_test.go index c2044de56c..ed1d8d078d 100644 --- a/cmd/entire/cli/repo_mirror_test.go +++ b/cmd/entire/cli/repo_mirror_test.go @@ -60,7 +60,7 @@ func TestAwaitMirrorReady(t *testing.T) { t.Run("ready resolves with no error", func(t *testing.T) { f := &fakeMirrorGetter{statuses: []coreapi.MirrorStatus{coreapi.MirrorStatusReady}} - status, err := awaitMirrorReady(ctx, f, "m", time.Second) + status, err := awaitMirrorReady(ctx, f, "m", time.Second, nil) require.NoError(t, err) require.Equal(t, coreapi.MirrorStatusReady, status) }) @@ -69,7 +69,7 @@ func TestAwaitMirrorReady(t *testing.T) { f := &fakeMirrorGetter{statuses: []coreapi.MirrorStatus{ coreapi.MirrorStatusProcessing, coreapi.MirrorStatusProcessing, coreapi.MirrorStatusReady, }} - status, err := awaitMirrorReady(ctx, f, "m", time.Second) + status, err := awaitMirrorReady(ctx, f, "m", time.Second, nil) require.NoError(t, err) require.Equal(t, coreapi.MirrorStatusReady, status) require.GreaterOrEqual(t, f.calls, 3) @@ -77,21 +77,21 @@ func TestAwaitMirrorReady(t *testing.T) { t.Run("failed returns errMirrorCloneFailed", func(t *testing.T) { f := &fakeMirrorGetter{statuses: []coreapi.MirrorStatus{coreapi.MirrorStatusFailed}} - status, err := awaitMirrorReady(ctx, f, "m", time.Second) + status, err := awaitMirrorReady(ctx, f, "m", time.Second, nil) require.ErrorIs(t, err, errMirrorCloneFailed) require.Equal(t, coreapi.MirrorStatusFailed, status) }) t.Run("suspended returns errMirrorSuspended", func(t *testing.T) { f := &fakeMirrorGetter{statuses: []coreapi.MirrorStatus{coreapi.MirrorStatusSuspended}} - status, err := awaitMirrorReady(ctx, f, "m", time.Second) + status, err := awaitMirrorReady(ctx, f, "m", time.Second, nil) require.ErrorIs(t, err, errMirrorSuspended) require.Equal(t, coreapi.MirrorStatusSuspended, status) }) t.Run("never-ready times out", func(t *testing.T) { f := &fakeMirrorGetter{statuses: []coreapi.MirrorStatus{coreapi.MirrorStatusProcessing}} - _, err := awaitMirrorReady(ctx, f, "m", 20*time.Millisecond) + _, err := awaitMirrorReady(ctx, f, "m", 20*time.Millisecond, nil) require.ErrorIs(t, err, context.DeadlineExceeded) }) } From 87592cf73aafcc1d7d1056a87aeb28511c94ee83 Mon Sep 17 00:00:00 2001 From: paul <423357+toothbrush@users.noreply.github.com> Date: Thu, 25 Jun 2026 13:02:29 +0930 Subject: [PATCH 13/17] Add blank line between progress block and summary table Co-Authored-By: Claude Opus 4.8 (1M context) Entire-Checkpoint: 4477af226732 --- cmd/entire/cli/repo_mirror_create_wizard.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cmd/entire/cli/repo_mirror_create_wizard.go b/cmd/entire/cli/repo_mirror_create_wizard.go index 24782bc4b7..0510a852bc 100644 --- a/cmd/entire/cli/repo_mirror_create_wizard.go +++ b/cmd/entire/cli/repo_mirror_create_wizard.go @@ -658,6 +658,8 @@ func reportMirrorResults(outW, errW io.Writer, results []mirrorResult) error { if len(results) == 0 { return nil } + // Headroom between the live progress block and the summary table. + fmt.Fprintln(outW) if err := printTable(outW, mirrorCreateResultColumns, results, mirrorCreateResultRow); err != nil { return err } From 7a0ca5c553fe770f6cf523423b54a72af4d924f2 Mon Sep 17 00:00:00 2001 From: paul <423357+toothbrush@users.noreply.github.com> Date: Thu, 25 Jun 2026 13:40:26 +0930 Subject: [PATCH 14/17] Address PR #1519 review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wizard: gate on an interactive terminal with a clear pointer at the non-interactive form; run the pickers via RunWithContext so Ctrl+C/ctx cancellation is handled cleanly (Cursor). - Wizard: a cancelled run no longer reports in-flight mirrors as failures — exit quietly (Cursor). - createAndAwaitMirror: restore the suspension check for an existing empty placement (one GetMirror) so suspended empties surface resume guidance instead of a benign "nothing to clone" (Cursor). - Wizard: suspended/failed results now carry the mirror id + resume command in the failure summary, matching the one-shot (Copilot). - auth token: enforce HTTPS on the core URL unless --insecure-http-auth, like auth status (Copilot). Co-Authored-By: Claude Opus 4.8 (1M context) Entire-Checkpoint: e2c7535c9951 --- cmd/entire/cli/auth.go | 11 +++++- cmd/entire/cli/repo_mirror.go | 20 ++++++++++- cmd/entire/cli/repo_mirror_create_wizard.go | 36 ++++++++++++++----- .../cli/repo_mirror_create_wizard_test.go | 22 ++++++++++++ 4 files changed, 78 insertions(+), 11 deletions(-) diff --git a/cmd/entire/cli/auth.go b/cmd/entire/cli/auth.go index 0d2e9b2ff2..cad9f586c2 100644 --- a/cmd/entire/cli/auth.go +++ b/cmd/entire/cli/auth.go @@ -148,11 +148,20 @@ func newAuthTokenCmd() *cobra.Command { RunE: func(cmd *cobra.Command, _ []string) error { // Refresh may exchange/refresh over the network; honor the // plain-HTTP opt-in before resolving so local dev cores work. - applyInsecureHTTPAuth(insecureHTTPAuth) + insecure := applyInsecureHTTPAuth(insecureHTTPAuth) target, err := resolveAuthStatusTarget(cmd.Context(), auth.Contexts, auth.RefreshedLoginToken) if err != nil { return err } + // Don't mint/print a bearer for an insecure core unless explicitly + // opted in — the token would otherwise be usable over plain HTTP. + // Mirrors `auth status`. + if !insecure && target.coreURL != "" { + if err := api.RequireSecureURL(target.coreURL); err != nil { + cmd.SilenceUsage = true + return fmt.Errorf("login server URL check: %w", err) + } + } if target.token == "" { cmd.SilenceUsage = true fmt.Fprintln(cmd.ErrOrStderr(), "Not logged in. Run 'entire login' to authenticate.") diff --git a/cmd/entire/cli/repo_mirror.go b/cmd/entire/cli/repo_mirror.go index 1702c8e1ee..a5fb9d3153 100644 --- a/cmd/entire/cli/repo_mirror.go +++ b/cmd/entire/cli/repo_mirror.go @@ -217,7 +217,25 @@ func createAndAwaitMirror(ctx context.Context, c *coreapi.Client, owner, repo, c return mirrorCreateOutcome{}, err } outcome := mirrorCreateOutcome{created: created} - if noWait || created.Empty { + if created.Empty { + // An empty upstream has nothing to clone, so don't poll for "ready" — it + // never would. But an *existing* placement can be suspended even when + // empty, and one status read surfaces that (a fresh create can't be + // suspended — suspension follows upstream access loss). Mirrors the old + // finishMirrorCreate behavior; the read is best-effort, so a transient + // GetMirror error just falls through to the benign "nothing to clone". + if !created.Created { + if m, gerr := c.GetMirror(ctx, coreapi.GetMirrorParams{MirrorId: created.MirrorId}); gerr == nil { + if s, ok := m.Status.Get(); ok && s == coreapi.MirrorStatusSuspended { + outcome.status = s + outcome.polled = true + return outcome, errMirrorSuspended + } + } + } + return outcome, nil + } + if noWait { return outcome, nil } status, werr := awaitMirrorReady(ctx, c, created.MirrorId, timeout, onStatus) diff --git a/cmd/entire/cli/repo_mirror_create_wizard.go b/cmd/entire/cli/repo_mirror_create_wizard.go index 0510a852bc..b652c1d209 100644 --- a/cmd/entire/cli/repo_mirror_create_wizard.go +++ b/cmd/entire/cli/repo_mirror_create_wizard.go @@ -242,6 +242,15 @@ func runMirrorCreateWizard(cmd *cobra.Command, noWait bool, waitTimeout time.Dur outW := cmd.OutOrStdout() errW := cmd.ErrOrStderr() + // The wizard drives interactive huh pickers, so it needs a real terminal. + // Without one (CI, pipes), fail fast with a clear pointer at the + // non-interactive form rather than letting huh error obscurely. + if !interactive.CanPromptInteractively() { + fmt.Fprintln(errW, "The mirror create wizard needs an interactive terminal.") + fmt.Fprintln(errW, "Run 'entire repo mirror create [cluster-host]' to create one non-interactively.") + return NewSilentError(errors.New("not an interactive terminal")) + } + insecure := insecureHTTPRequested(cmd) if insecure { auth.EnableInsecureHTTP() @@ -271,7 +280,7 @@ func runMirrorCreateWizard(cmd *cobra.Command, noWait bool, waitTimeout time.Dur fmt.Fprintln(errW, "Run 'entire repo mirror list --show-available' to see what's onboardable.") return nil } - selectedRepos, err := pickRepos(outW, repos) + selectedRepos, err := pickRepos(ctx, outW, repos) if err != nil || len(selectedRepos) == 0 { return err } @@ -287,7 +296,7 @@ func runMirrorCreateWizard(cmd *cobra.Command, noWait bool, waitTimeout time.Dur if len(regions) == 0 { return errors.New("no regions available to mirror into") } - selectedRegions, err := pickRegions(outW, regions, jurisdiction) + selectedRegions, err := pickRegions(ctx, outW, regions, jurisdiction) if err != nil || len(selectedRegions) == 0 { return err } @@ -296,6 +305,11 @@ func runMirrorCreateWizard(cmd *cobra.Command, noWait bool, waitTimeout time.Dur targets := mirrorTargets(selectedRepos, selectedRegions) results := createMirrors(ctx, errW, targets, noWait, waitTimeout) + // A cancelled run (Ctrl+C) leaves in-flight mirrors looking like errors; + // exit quietly instead of reporting them as "N mirror(s) failed". + if ctx.Err() != nil { + return NewSilentError(ctx.Err()) + } return reportMirrorResults(outW, errW, results) } @@ -336,8 +350,8 @@ func ensureMirrorWizardAuth(ctx context.Context, errW io.Writer, insecure bool) } // pickRepos runs the repo multi-select and returns the chosen available -// mirrors. A clean cancel (Ctrl+C) returns (nil, nil). -func pickRepos(w io.Writer, repos []coreapi.AvailableMirror) ([]coreapi.AvailableMirror, error) { +// mirrors. A clean cancel (Ctrl+C / cancelled ctx) returns (nil, nil). +func pickRepos(ctx context.Context, w io.Writer, repos []coreapi.AvailableMirror) ([]coreapi.AvailableMirror, error) { repoByKey := make(map[string]coreapi.AvailableMirror, len(repos)) options := make([]huh.Option[string], len(repos)) for i, m := range repos { @@ -363,7 +377,7 @@ func pickRepos(w io.Writer, repos []coreapi.AvailableMirror) ([]coreapi.Availabl Value(&selected), ), ) - if err := form.Run(); err != nil { + if err := form.RunWithContext(ctx); err != nil { return nil, handleFormCancellation(w, "Mirror create", err) } @@ -378,7 +392,7 @@ func pickRepos(w io.Writer, repos []coreapi.AvailableMirror) ([]coreapi.Availabl // pickRegions runs the region multi-select, pre-selecting the default cluster // for the caller's jurisdiction. A clean cancel returns (nil, nil). -func pickRegions(w io.Writer, regions []regionChoice, jurisdiction string) ([]regionChoice, error) { +func pickRegions(ctx context.Context, w io.Writer, regions []regionChoice, jurisdiction string) ([]regionChoice, error) { opts, defaults := clusterChoices(regions, jurisdiction) regionByHost := make(map[string]regionChoice, len(regions)) for _, r := range regions { @@ -403,7 +417,7 @@ func pickRegions(w io.Writer, regions []regionChoice, jurisdiction string) ([]re Value(&selected), ), ) - if err := form.Run(); err != nil { + if err := form.RunWithContext(ctx); err != nil { return nil, handleFormCancellation(w, "Mirror create", err) } @@ -521,9 +535,13 @@ func createOneMirror(ctx context.Context, t mirrorTarget, c *coreapi.Client, cli case coreapi.MirrorStatusReady: res.status = mirrorStatusReady case coreapi.MirrorStatusSuspended: - res.status, res.err = mirrorStatusSuspended, err + // Carry the mirror id + resume command so the failure summary is + // actionable, matching the one-shot's explainSuspendedMirror guidance. + res.status, res.err = mirrorStatusSuspended, + fmt.Errorf("suspended — an operator can resume it: entire-core admin mirrors resume %s", outcome.created.MirrorId) case coreapi.MirrorStatusFailed: - res.status, res.err = mirrorStatusFailed, err + res.status, res.err = mirrorStatusFailed, + fmt.Errorf("initial clone failed (mirror %s)", outcome.created.MirrorId) case coreapi.MirrorStatusProcessing: nonTerminal() default: diff --git a/cmd/entire/cli/repo_mirror_create_wizard_test.go b/cmd/entire/cli/repo_mirror_create_wizard_test.go index 201f7a302c..371be5eab9 100644 --- a/cmd/entire/cli/repo_mirror_create_wizard_test.go +++ b/cmd/entire/cli/repo_mirror_create_wizard_test.go @@ -2,13 +2,35 @@ package cli import ( "bytes" + "context" "testing" + "time" + "github.com/spf13/cobra" "github.com/stretchr/testify/require" "github.com/entireio/cli/internal/coreapi" ) +func TestRunMirrorCreateWizard_RequiresTTY(t *testing.T) { + t.Parallel() + // In-process tests are non-interactive, so the wizard must refuse before + // touching auth or the network, pointing at the non-interactive form. + cmd := &cobra.Command{} + var out, errOut bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&errOut) + cmd.SetContext(context.Background()) + + err := runMirrorCreateWizard(cmd, false, time.Minute) + + var silent *SilentError + require.ErrorAs(t, err, &silent) + require.Empty(t, out.String(), "stdout must stay clean") + require.Contains(t, errOut.String(), "interactive terminal") + require.Contains(t, errOut.String(), "entire repo mirror create ") +} + func TestSelectableAvailableRepos(t *testing.T) { t.Parallel() in := []coreapi.AvailableMirror{ From 0b20568fc7372939e3b880f4646b5907136ab426 Mon Sep 17 00:00:00 2001 From: paul <423357+toothbrush@users.noreply.github.com> Date: Thu, 25 Jun 2026 13:49:03 +0930 Subject: [PATCH 15/17] Address PR #1519 review round 2 - handleFormCancellation: also treat a cancelled/expired context as a clean cancel, so a RunWithContext form whose context is cancelled exits quietly instead of "prompt failed: huh: context canceled" (Cursor). - Drop internal terminology from suspended/failed user messages: the wizard now says "the mirror is suspended; contact support" / "the initial clone failed; contact support", and explainSuspendedMirror points at support instead of the internal `entire-core admin mirrors resume` command (Cursor). Co-Authored-By: Claude Opus 4.8 (1M context) Entire-Checkpoint: 9ea51e10ee42 --- cmd/entire/cli/repo_mirror_create_wizard.go | 8 ++------ cmd/entire/cli/repo_mirror_probe.go | 18 +++++++++--------- cmd/entire/cli/repo_mirror_test.go | 9 ++++++--- cmd/entire/cli/utils.go | 9 ++++++--- 4 files changed, 23 insertions(+), 21 deletions(-) diff --git a/cmd/entire/cli/repo_mirror_create_wizard.go b/cmd/entire/cli/repo_mirror_create_wizard.go index b652c1d209..96b2bc8a38 100644 --- a/cmd/entire/cli/repo_mirror_create_wizard.go +++ b/cmd/entire/cli/repo_mirror_create_wizard.go @@ -535,13 +535,9 @@ func createOneMirror(ctx context.Context, t mirrorTarget, c *coreapi.Client, cli case coreapi.MirrorStatusReady: res.status = mirrorStatusReady case coreapi.MirrorStatusSuspended: - // Carry the mirror id + resume command so the failure summary is - // actionable, matching the one-shot's explainSuspendedMirror guidance. - res.status, res.err = mirrorStatusSuspended, - fmt.Errorf("suspended — an operator can resume it: entire-core admin mirrors resume %s", outcome.created.MirrorId) + res.status, res.err = mirrorStatusSuspended, errors.New("the mirror is suspended; contact support") case coreapi.MirrorStatusFailed: - res.status, res.err = mirrorStatusFailed, - fmt.Errorf("initial clone failed (mirror %s)", outcome.created.MirrorId) + res.status, res.err = mirrorStatusFailed, errors.New("the initial clone failed; contact support") case coreapi.MirrorStatusProcessing: nonTerminal() default: diff --git a/cmd/entire/cli/repo_mirror_probe.go b/cmd/entire/cli/repo_mirror_probe.go index da1c238a92..ec27d12cb4 100644 --- a/cmd/entire/cli/repo_mirror_probe.go +++ b/cmd/entire/cli/repo_mirror_probe.go @@ -150,16 +150,16 @@ func classifyWaitContextErr(err error) error { return fmt.Errorf("timed out waiting for initial clone: %w", err) } -// explainSuspendedMirror prints operator-recovery guidance for a suspended -// placement: the cluster won't serve clones until upstream GitHub access is -// restored and an operator resumes it. Suspension usually follows upstream -// access loss (App uninstalled, repo went private, or a transient API error). +// explainSuspendedMirror tells the user a suspended placement can't be served +// and to contact support. Suspension usually follows a loss of upstream GitHub +// access (App uninstalled, repo went private, or a transient API error); the +// fix is operator-side, so we point at support rather than leaking an internal +// admin command. func explainSuspendedMirror(w io.Writer, mirrorID string) { fmt.Fprintf(w, - "\nMirror %s is registered but suspended — the cluster won't serve it.\n"+ + "\nMirror %s is registered but suspended, so it can't be cloned yet.\n"+ "This usually means upstream GitHub access was lost (App uninstalled,\n"+ - "the repo went private, or a transient API error). An operator can\n"+ - "re-enable it once access is restored:\n"+ - " entire-core admin mirrors resume %s\n", - mirrorID, mirrorID) + "the repo went private, or a transient API error). Contact support to\n"+ + "restore it.\n", + mirrorID) } diff --git a/cmd/entire/cli/repo_mirror_test.go b/cmd/entire/cli/repo_mirror_test.go index ed1d8d078d..7e2cd247c8 100644 --- a/cmd/entire/cli/repo_mirror_test.go +++ b/cmd/entire/cli/repo_mirror_test.go @@ -22,7 +22,9 @@ func TestExplainSuspendedMirror(t *testing.T) { explainSuspendedMirror(&buf, id) out := buf.String() require.Contains(t, out, id, "message must name the mirror") - require.Contains(t, out, "entire-core admin mirrors resume "+id, "message must give the resume command") + require.Contains(t, out, "suspended") + require.Contains(t, out, "Contact support", "must point at support, not an internal admin command") + require.NotContains(t, out, "entire-core", "must not leak internal terminology") } // fakeMirrorGetter feeds awaitMirrorReady a scripted sequence of statuses (the @@ -142,14 +144,15 @@ func TestReportOneShotMirror(t *testing.T) { require.Contains(t, out.String(), "git clone "+mirrorURL) }) - t.Run("suspended surfaces resume guidance as SilentError", func(t *testing.T) { + t.Run("suspended surfaces support guidance as SilentError", func(t *testing.T) { t.Parallel() var out, errW bytes.Buffer outcome := mirrorCreateOutcome{created: mk(false, false), status: coreapi.MirrorStatusSuspended, polled: true} err := reportOneShotMirror(&out, &errW, outcome, errMirrorSuspended) var silent *SilentError require.ErrorAs(t, err, &silent) - require.Contains(t, errW.String(), "entire-core admin mirrors resume "+id) + require.Contains(t, errW.String(), "Contact support") + require.NotContains(t, errW.String(), "entire-core") require.NotContains(t, out.String(), "git clone") }) diff --git a/cmd/entire/cli/utils.go b/cmd/entire/cli/utils.go index c7baf60923..21136757cc 100644 --- a/cmd/entire/cli/utils.go +++ b/cmd/entire/cli/utils.go @@ -28,10 +28,13 @@ func NewAccessibleForm(groups ...*huh.Group) *huh.Form { } // handleFormCancellation handles cancellation from huh form prompts. -// User abort (Ctrl+C) and timeout both print a cancelled message and return nil. -// Other errors are wrapped with the action name for context. +// User abort (Ctrl+C), timeout, and a cancelled/expired context (when the form +// ran via RunWithContext and the command's context was cancelled) all print a +// cancelled message and return nil. Other errors are wrapped with the action +// name for context. func handleFormCancellation(w io.Writer, action string, err error) error { - if errors.Is(err, huh.ErrUserAborted) || errors.Is(err, huh.ErrTimeout) { + if errors.Is(err, huh.ErrUserAborted) || errors.Is(err, huh.ErrTimeout) || + errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { fmt.Fprintf(w, "%s cancelled.\n", action) return nil } From e5692a01907a37722e2471cfe968b58ab69cc69f Mon Sep 17 00:00:00 2001 From: paul <423357+toothbrush@users.noreply.github.com> Date: Thu, 25 Jun 2026 14:02:30 +0930 Subject: [PATCH 16/17] Tolerate transient poll errors during clone wait MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit awaitMirrorReady previously aborted the whole wait on any GetMirror error, so a brief network/API glitch during a long initial clone failed mirror create even though the clone was still progressing. Retry on the poll interval, giving up only after maxConsecutivePollErrors consecutive failures (resets on success) or when the context ends — a persistent error (deleted mirror, revoked auth) still surfaces instead of spinning to the deadline. (Cursor) Co-Authored-By: Claude Opus 4.8 (1M context) Entire-Checkpoint: 5d285b69feaf --- cmd/entire/cli/repo_mirror_probe.go | 51 +++++++++++++++++++---------- cmd/entire/cli/repo_mirror_test.go | 32 +++++++++++++++--- 2 files changed, 61 insertions(+), 22 deletions(-) diff --git a/cmd/entire/cli/repo_mirror_probe.go b/cmd/entire/cli/repo_mirror_probe.go index ec27d12cb4..3c4ec97298 100644 --- a/cmd/entire/cli/repo_mirror_probe.go +++ b/cmd/entire/cli/repo_mirror_probe.go @@ -67,6 +67,13 @@ func parseGitHubURL(rawURL string) (owner, repo string, err error) { // for the initial clone. A package var (not const) so tests can shorten it. var mirrorPollInterval = 2 * time.Second +// maxConsecutivePollErrors bounds how many back-to-back GetMirror failures the +// clone wait tolerates before giving up. A brief network/API glitch during a +// long initial clone shouldn't fail the create, but a persistent error +// (deleted mirror, revoked auth) should surface rather than spin to the +// deadline. The counter resets on any successful poll. +const maxConsecutivePollErrors = 5 + var ( // errMirrorCloneFailed reports the mirror's initial clone reached the // terminal "failed" status — the server gave up cloning the upstream. @@ -89,7 +96,8 @@ type mirrorStatusGetter interface { // - nil when ready (the repo is clonable) // - errMirrorCloneFailed when the initial clone failed // - errMirrorSuspended when the placement is suspended -// - a timeout/transport err when the wait deadline passed or a poll errored +// - a timeout/transport err when the wait deadline passed, or polls kept +// erroring past maxConsecutivePollErrors (transient glitches are retried) // // "processing" keeps the loop running. This replaces the old smart-HTTP // info/refs probe: the control plane now reports clone readiness directly via @@ -108,28 +116,37 @@ func awaitMirrorReady(ctx context.Context, c mirrorStatusGetter, mirrorID string defer ticker.Stop() var last coreapi.MirrorStatus + var consecutiveErrs int for { m, err := c.GetMirror(ctx, coreapi.GetMirrorParams{MirrorId: mirrorID}) - if err != nil { + switch { + case err != nil: if ctx.Err() != nil { return last, classifyWaitContextErr(ctx.Err()) } - return last, fmt.Errorf("poll mirror status: %w", err) - } - if s, ok := m.Status.Get(); ok { - last = s - if onStatus != nil { - onStatus(s) + // Tolerate transient glitches: the clone may still be progressing, + // so retry on the next tick. Only give up once errors persist. + consecutiveErrs++ + if consecutiveErrs >= maxConsecutivePollErrors { + return last, fmt.Errorf("poll mirror status: %w", err) } - switch s { - case coreapi.MirrorStatusReady: - return s, nil - case coreapi.MirrorStatusFailed: - return s, errMirrorCloneFailed - case coreapi.MirrorStatusSuspended: - return s, errMirrorSuspended - case coreapi.MirrorStatusProcessing: - // keep waiting + default: + consecutiveErrs = 0 + if s, ok := m.Status.Get(); ok { + last = s + if onStatus != nil { + onStatus(s) + } + switch s { + case coreapi.MirrorStatusReady: + return s, nil + case coreapi.MirrorStatusFailed: + return s, errMirrorCloneFailed + case coreapi.MirrorStatusSuspended: + return s, errMirrorSuspended + case coreapi.MirrorStatusProcessing: + // keep waiting + } } } select { diff --git a/cmd/entire/cli/repo_mirror_test.go b/cmd/entire/cli/repo_mirror_test.go index 7e2cd247c8..a7a983be60 100644 --- a/cmd/entire/cli/repo_mirror_test.go +++ b/cmd/entire/cli/repo_mirror_test.go @@ -29,21 +29,28 @@ func TestExplainSuspendedMirror(t *testing.T) { // fakeMirrorGetter feeds awaitMirrorReady a scripted sequence of statuses (the // last entry repeats) or a fixed error, standing in for *coreapi.Client.GetMirror. +// errsBefore makes the first N calls return a transient error before the status +// sequence begins, to exercise the poll's retry tolerance. type fakeMirrorGetter struct { - statuses []coreapi.MirrorStatus - err error - calls int + statuses []coreapi.MirrorStatus + err error + errsBefore int + calls int } func (f *fakeMirrorGetter) GetMirror(_ context.Context, _ coreapi.GetMirrorParams) (*coreapi.Mirror, error) { + n := f.calls + f.calls++ if f.err != nil { return nil, f.err } - i := f.calls + if n < f.errsBefore { + return nil, errors.New("transient: connection reset") + } + i := n - f.errsBefore if i >= len(f.statuses) { i = len(f.statuses) - 1 } - f.calls++ m := &coreapi.Mirror{} m.Status = coreapi.NewOptMirrorStatus(f.statuses[i]) return m, nil @@ -96,6 +103,21 @@ func TestAwaitMirrorReady(t *testing.T) { _, err := awaitMirrorReady(ctx, f, "m", 20*time.Millisecond, nil) require.ErrorIs(t, err, context.DeadlineExceeded) }) + + t.Run("transient errors are tolerated, then ready", func(t *testing.T) { + // Fewer consecutive errors than the cap, so the poll rides them out. + f := &fakeMirrorGetter{errsBefore: maxConsecutivePollErrors - 1, statuses: []coreapi.MirrorStatus{coreapi.MirrorStatusReady}} + status, err := awaitMirrorReady(ctx, f, "m", time.Second, nil) + require.NoError(t, err) + require.Equal(t, coreapi.MirrorStatusReady, status) + }) + + t.Run("persistent errors give up after the cap", func(t *testing.T) { + f := &fakeMirrorGetter{err: errors.New("boom")} + _, err := awaitMirrorReady(ctx, f, "m", time.Second, nil) + require.ErrorContains(t, err, "poll mirror status") + require.Equal(t, maxConsecutivePollErrors, f.calls, "should stop at the cap, not spin to the deadline") + }) } // TestReportOneShotMirror exercises the one-shot create's presentation across From 8fc7b1cdfcbf2bec37a335fd3c898ccdb7568d3c Mon Sep 17 00:00:00 2001 From: paul <423357+toothbrush@users.noreply.github.com> Date: Thu, 25 Jun 2026 14:19:52 +0930 Subject: [PATCH 17/17] Tolerate trailing slash in cluster publicUrl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hostFromPublicURL rejected any non-empty path, so a catalog entry like https://aws-us-east-2.entire.io/ (Path == "/") was dropped in clustersToRegions — silently removing clusters and risking "no regions available to mirror into" if the control plane emits trailing slashes. publicUrl is a trusted catalog field, so accept a bare "/" path; real paths/queries/fragments/userinfo are still refused. (Trail review) Co-Authored-By: Claude Opus 4.8 (1M context) Entire-Checkpoint: 32af87a69f30 --- cmd/entire/cli/repo_mirror_create_wizard.go | 9 ++++++--- cmd/entire/cli/repo_mirror_create_wizard_test.go | 2 ++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/cmd/entire/cli/repo_mirror_create_wizard.go b/cmd/entire/cli/repo_mirror_create_wizard.go index 96b2bc8a38..7646f4660a 100644 --- a/cmd/entire/cli/repo_mirror_create_wizard.go +++ b/cmd/entire/cli/repo_mirror_create_wizard.go @@ -103,9 +103,12 @@ func hostFromPublicURL(raw string) (string, error) { // Reject anything beyond scheme://host[:port]. url.Parse demotes the // `host@evil.com` userinfo trick into u.User (leaving u.Host=evil.com) and // stashes a trailing path in u.Path, neither of which validateClusterHost - // would otherwise see. Same belt-and-suspenders the positional - // arg gets — the host flows into clone URLs and the STS audience. - if u.User != nil || u.Path != "" || u.RawQuery != "" || u.Fragment != "" { + // would otherwise see. A bare "/" path is tolerated: publicUrl is a trusted + // catalog field, and a trailing slash (https://host/) is benign — rejecting + // it would silently drop the cluster and could leave the wizard with no + // regions. Anything richer (a real path, query, fragment, userinfo) is still + // refused, since the host flows into clone URLs and the STS audience. + if u.User != nil || (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.Fragment != "" { return "", fmt.Errorf("public_url %q must be scheme://host[:port] only", raw) } if err := validateClusterHost(u.Host); err != nil { diff --git a/cmd/entire/cli/repo_mirror_create_wizard_test.go b/cmd/entire/cli/repo_mirror_create_wizard_test.go index 371be5eab9..42fe6a4fea 100644 --- a/cmd/entire/cli/repo_mirror_create_wizard_test.go +++ b/cmd/entire/cli/repo_mirror_create_wizard_test.go @@ -67,6 +67,8 @@ func TestHostFromPublicURL(t *testing.T) { {name: "bare host", in: "eu-west-1.entire.io", want: "eu-west-1.entire.io"}, {name: "host with port", in: "https://localhost:8080", want: "localhost:8080"}, {name: "trims space", in: " https://aws-us-east-2.entire.io ", want: "aws-us-east-2.entire.io"}, + // A trailing slash is a benign catalog shape, not a path injection. + {name: "trailing slash", in: "https://aws-us-east-2.entire.io/", want: "aws-us-east-2.entire.io"}, {name: "empty", in: "", wantErr: true}, // userinfo trick rejected by validateClusterHost {name: "userinfo injection", in: "https://aws-us-east-2.entire.io@evil.com", wantErr: true},