Help us shape the future of Go! Take the 2026 Go Developer Survey

actioncatalog

package
v3.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 9, 2026 License: MIT Imports: 12 Imported by: 0

Documentation

Overview

Package actioncatalog provides the canonical GitLab action catalog shared by catalog-backed MCP tool surfaces.

The catalog is the intermediate action core between typed GitLab handlers and the public meta and dynamic tool surfaces. It stores executable actions as deterministic groups, preserving route metadata such as input schemas, output schemas, destructive flags, action aliases, tags, icons, descriptions, and formatter hooks.

Action IDs use the stable domain.action form derived from the backing meta-tool name and action name. For example, gitlab_project/create becomes project.create, and gitlab_merge_request/list becomes merge_request.list. Dynamic mode uses these IDs directly, while meta-tools expose the same routes through action dispatch inside domain tools.

This package is not the registry for individual MCP tools. Individual tools are still registered directly by internal/tools.RegisterAll for compatibility. Meta-tools and dynamic tools consume this catalog through adapters such as internal/tools.RegisterMetaCatalog and internal/tools/dynamic.NewRegistryFromCatalog.

Invariants

Catalog construction must be deterministic. Groups preserve explicit action order for user-facing descriptions, cloning avoids mutable alias/schema sharing between surfaces, and validation rejects duplicate action IDs or ambiguous aliases before tools are registered.

Index

Constants

This section is empty.

Variables

View Source
var SeeAlsoClause = regexp.MustCompile(`See also: ([a-z0-9_.]+(?:, [a-z0-9_.]+)*)\.`)

SeeAlsoClause matches the cross-reference sentence an individual tool's Description ends with, "See also: gitlab_a, gitlab_b.", and, because the character class admits dots, a clause written in canonical IDs or already projected into a surface's entry IDs ("See also: widget.create, gitlab_widget.get."). The terminating literal dot still matches: the greedy class backtracks one character off the final name. The first submatch is the comma-separated list of names.

It is the one definition of the clause, because two readers have to agree on it and disagreeing fails silently. internal/resources rewrites what it matches into the names of the surface a manifest serves, and cmd/audit_action_ids passes over what it matches when it holds a Description to the rule that served prose names no tool, since that rewrite is what makes an individual tool name right there. With a copy in each, a manifest pattern narrowed later would stop rewriting text the gate still skipped, and tool names would reach the dynamic and meta surfaces verbatim with the gate green.

Functions

func ClassicNeedOf added in v3.2.0

func ClassicNeedOf(id string, readOnly bool) finegrained.ClassicScope

ClassicNeedOf is Action.ClassicNeed for an action known by its canonical ID and classification alone, which is how a standalone surface tool is held to the same rule as the catalog's actions.

func DomainFromToolName

func DomainFromToolName(toolName string) string

DomainFromToolName returns the canonical dynamic domain for a meta-tool name.

func RewriteSeeAlso added in v3.2.0

func RewriteSeeAlso(description string, resolve SeeAlsoResolver) string

RewriteSeeAlso projects the "See also:" clause of an individual-surface description into the active surface's identifier namespace.

The specs hand-write these clauses once, in individual-tool names; on the dynamic and meta surfaces those names are not invocable, and the manifest instructions tell the model to pass entry IDs, so emitting the individual names there contradicts the same document two lines later. The standalone surface tools (the guided flows and project discovery) write theirs in canonical IDs instead, because their description reaches every surface's tools/list and find results verbatim, which this projection never sees. An ID is rewritten here like a tool name is, which only the dynamic manifest does to theirs, where an ID maps to itself: the meta and individual surfaces register those tools beside a catalog that does not carry them, so their manifests list them as direct entries and serve the description, the clause's canonical IDs included, verbatim, and gitlab://tools/{id} resolves those IDs on every surface.

A name the resolver does not know is dropped, not passed through: on one instance the catalog is tier-filtered, so a Free-tier server legitimately cannot resolve a reference to a Premium action, and a name that resolves to nothing on the whole instance is not a reference, it is noise. Stale names cannot hide behind this: the guard test in internal/resources checks the hand-written clauses against the full unfiltered catalog, where only a genuinely wrong name fails. A clause left empty is removed whole, and the spaces and line breaks it leaves at the end of the text with it.

func ServedDescription added in v3.2.0

func ServedDescription(action Action, resolve SeeAlsoResolver) string

ServedDescription is the description gitlab://tools serves for an action on a surface whose "See also:" names resolve through resolve: the individual tool's Description with its clause rewritten by RewriteSeeAlso, or, for an action with no Description, its Usage line verbatim.

It is the one definition of that text, because two readers have to agree on it: internal/resources serves it, and cmd/gen_tool_reference publishes it on the site as the description the dynamic surface serves. A copy in the generator had already come to rewrite a Usage line the server serves as it is, with nothing to notice, since its gate compares the generator with its own output.

func ToActionMaps

func ToActionMaps(catalog *Catalog) map[string]toolutil.ActionMap

ToActionMaps returns legacy route maps for compatibility with existing schema resources, audits, and registration paths.

Types

type Action

type Action struct {
	ID                     ActionID
	ToolName               string
	Domain                 string
	Name                   string
	Route                  toolutil.ActionRoute
	SchemaURI              string
	Aliases                []string
	Tags                   []string
	Usage                  string
	RelatedActions         []string
	Compatibility          toolutil.CompatibilityPolicy
	ReadOnly               bool
	Edition                string
	GitLabDotComOnly       bool
	OwnerPackage           string
	IndividualTool         toolutil.IndividualToolSpec
	ContentKind            string
	NotFoundPolicy         string
	EmbeddedResourcePolicy string
	EmbeddedResource       string
	RichResultPolicy       string
	SchemaValidationNotes  []string
	RuntimeValidationNotes []string
	SpecBacked             bool
	Destructive            bool
	Idempotent             bool
	OpenWorld              bool
	// FineGrained is what a fine-grained personal access token needs for the
	// action, read from the generated table by canonical ID when the action
	// joins a catalog; nil when the table holds no row for it. It points into
	// the one table, so every catalog shares it.
	FineGrained *finegrained.Requirement
}

Action describes one executable GitLab action in the canonical catalog.

func ActionsFromSpecs

func ActionsFromSpecs(specs []toolutil.ActionSpec) ([]Action, error)

ActionsFromSpecs projects canonical action specs into catalog actions.

func (Action) ClassicNeed added in v3.2.0

func (a Action) ClassicNeed() finegrained.ClassicScope

ClassicNeed is the classic scope the action needs: what the generated table derived from the requests it sends (finegrained.Requirement.Classic), or, for an action the table holds no known scope for, the one its read or write classification gives, read_api for a read and api for anything else. R-GRANT fails the build on an action with no row, so the fallback is what an action added since the last generation is judged by until the table is regenerated.

type ActionID

type ActionID string

ActionID is the stable dynamic identifier for one GitLab action.

type Catalog

type Catalog struct {
	// contains filtered or unexported fields
}

Catalog stores deterministic groups and action lookup indexes. A Catalog is intended to be mutated during single-threaded initialization and then shared read-only; concurrent mutation is not supported.

What a catalog shares

Every accessor returns copies of the Group and Action values, so a caller may set fields and append to slices without reaching the catalog. What the copies do not include is the route metadata: the InputSchema, OutputSchema and ParameterGuidance maps of every route are frozen and shared, between the copies, the catalog, and every other catalog derived from it by filtering or binding. The accessors used to deep-copy them, and in the HTTP pool, where a catalog is consulted several times for each of a thousand credentials, those copies were half of the process's heap. A consumer that must change a schema derives its own through toolutil.DeriveSchema.

Shared and bound catalogs

A catalog built once per configuration and cached for the process is marked with Catalog.MarkShared; its handlers are bound to no credential. Each server gets Catalog.BindTo of it: the same groups and actions, with the handlers rebuilt for that server's client. A bound catalog remembers the shared one it came from as its origin, and consumers that build something client-independent from a catalog (the dynamic search index, the tool manifest, the telemetry identifier) key it on Catalog.SharedOrigin so every server of one configuration shares one.

func FromActionMaps

func FromActionMaps(routes map[string]toolutil.ActionMap) *Catalog

FromActionMaps converts legacy route maps into a canonical catalog.

func FromActionMapsWithError

func FromActionMapsWithError(routes map[string]toolutil.ActionMap) (*Catalog, error)

FromActionMapsWithError converts legacy route maps into a canonical catalog and reports invalid groups instead of panicking.

func NewCatalog

func NewCatalog() *Catalog

NewCatalog creates an empty action catalog.

func (*Catalog) Action

func (c *Catalog) Action(id ActionID) (Action, bool)

Action returns a copy of one action by canonical ID, sharing the route schemas as described on Catalog.

func (*Catalog) ActionMaps

func (c *Catalog) ActionMaps() map[string]toolutil.ActionMap

ActionMaps returns a legacy route snapshot keyed by tool and action. The maps are the caller's; the routes share their schemas with the catalog.

func (*Catalog) Actions

func (c *Catalog) Actions() []Action

Actions returns copies of all actions sorted by canonical ID, sharing the route schemas as described on Catalog.

func (*Catalog) AddAction

func (c *Catalog) AddAction(toolName string, action Action, groupOptions ...GroupOptions) error

AddAction adds one action to an existing or newly-created group. When the group does not exist, callers may provide GroupOptions so the synthesized group carries the same metadata as a normal catalog group.

func (*Catalog) AddGroup

func (c *Catalog) AddGroup(group Group) error

AddGroup adds a complete group to the catalog.

func (*Catalog) BindTo

func (c *Catalog) BindTo(client *gitlabclient.Client) *Catalog

BindTo returns this catalog with every handler rebuilt for client, through toolutil.ActionRoute.BindTo. The groups, the actions and the route metadata are the same; only the handlers are new, which is the whole per-credential cost of a surface built from a shared catalog. The result remembers this catalog's Catalog.SharedOrigin as its own.

func (*Catalog) Clone

func (c *Catalog) Clone() *Catalog

Clone returns a copy of the catalog that may be added to and filtered without reaching the original, sharing the route schemas as described on Catalog. The copy is a new catalog with no shared origin: what is added to it afterwards makes it a different action set.

func (*Catalog) CountActions

func (c *Catalog) CountActions() int

CountActions returns the number of actions in the catalog.

func (*Catalog) CountGroups

func (c *Catalog) CountGroups() int

CountGroups returns the number of groups in the catalog.

func (*Catalog) ExcludedActionIDs

func (c *Catalog) ExcludedActionIDs(excludeTools []string) []string

ExcludedActionIDs returns the canonical action IDs excludeTools removes from this catalog, sorted.

The tool surface is not the only request path to a GitLab object: a resource template returns the same data through the same credential, and its registration knows nothing about tool names. Resolving the operator's entries — group name, individual tool name or action ID alike — to canonical IDs here is what lets that other surface apply the same narrowing from one table keyed by one kind of name, instead of repeating this matcher.

func (*Catalog) Filter

func (c *Catalog) Filter(opts FilterOptions) *Catalog

Filter applies all catalog-level filters in a deterministic order.

Its exclusion step is Catalog.FilterExcludedTools, whose warning is computed against this catalog alone, so it is not how the server applies or reports --exclude-tools; see that method for why. The server calls tools.ExcludeFromCatalog instead.

func (*Catalog) FilterAllowedToolNames

func (c *Catalog) FilterAllowedToolNames(toolNames []string) *Catalog

FilterAllowedToolNames returns a cloned catalog with only explicitly allowed tools.

func (*Catalog) FilterExcludedToolNames

func (c *Catalog) FilterExcludedToolNames(excludeTools []string) (filtered *Catalog, unmatched []string)

FilterExcludedToolNames returns a cloned catalog without the tools and actions named by excludeTools, together with the entries that matched nothing in this catalog.

An entry is matched against three names, because three names reach the same action depending on which surface is registered:

  • a group's meta-tool name ("gitlab_issue"), which removes the group and every action in it;
  • an action's individual tool name ("gitlab_issue_delete"), which removes that one action;
  • the canonical action ID ("issue.delete"), which the dynamic surface takes directly, likewise.

Only the group name matched before. The default surface is dynamic, where the two visible tools reach every action by canonical ID, so an operator who hardened a deployment with EXCLUDE_TOOLS=gitlab_issue_delete got a server that still executed it and a startup log that said nothing was excluded.

Aliases are deliberately not matched. Alias resolution is fuzzy by design, and an exclusion that removes more than the operator named is a different defect rather than a fix for this one.

A group whose every action was excluded is dropped: a dispatcher that can only refuse is worse than no tool at all. A group that was already empty is left alone so catalog validation can still report it.

func (*Catalog) FilterExcludedTools

func (c *Catalog) FilterExcludedTools(excludeTools []string) *Catalog

FilterExcludedTools returns a cloned catalog without the tools and actions named by excludeTools.

Entries that name nothing in this catalog are logged at WARN rather than refused: one configuration is routinely reused across Free, Premium and Ultimate instances, and a lower-tier catalog legitimately lacks tools the same file names. The warning is deliberately operator-facing only: the excluded actions must stay out of the client-facing withheld list, or the dynamic registry would name back the very actions the operator removed.

It is not how the server reports a deployment's exclusions. The standalone utilities (gitlab_discover_project, the gitlab_interactive_* flows) are in none of the catalogs the surfaces filter, yet an exclusion removes them on every surface, so a warning computed here names an entry that works. The server calls Catalog.FilterExcludedToolNames instead, from tools.ExcludeFromCatalog, and reports only what neither the catalog nor a standalone utility answers.

func (*Catalog) FilterReachableWith added in v3.2.0

func (c *Catalog) FilterReachableWith(token finegrained.ClassicScope) *Catalog

FilterReachableWith returns a catalog of the actions a token whose strongest classic scope is token reaches (Action.ClassicNeed), with the groups left empty dropped. A kept group is read-only only when every action it keeps is, since a token carrying read_api reaches some actions that write (one that writes a file on the server's machine, one GitLab authenticates by another credential the caller passes) and the meta tool's annotation must not claim otherwise.

func (*Catalog) FilterReadOnlyActions

func (c *Catalog) FilterReadOnlyActions() *Catalog

FilterReadOnlyActions returns a cloned catalog containing only read-only actions, filtered at action granularity rather than group granularity.

A group that mixes reads and writes keeps its read-only actions instead of disappearing entirely, which is what read-only mode promises: mutating operations are removed, reads keep working. Groups left without a single read-only action are dropped, and every surviving group is marked ReadOnly so derived tool annotations agree with the actions the group actually exposes (otherwise read-only tool pruning would remove the very tools this filter just built).

func (*Catalog) FilterReadOnlyGroups

func (c *Catalog) FilterReadOnlyGroups() *Catalog

FilterReadOnlyGroups returns a cloned catalog containing only read-only groups.

func (*Catalog) Group

func (c *Catalog) Group(toolName string) (Group, bool)

Group returns a copy of one group by tool name, sharing the route schemas as described on Catalog.

func (*Catalog) Groups

func (c *Catalog) Groups() []Group

Groups returns copies of all groups sorted by tool name, sharing the route schemas as described on Catalog.

func (*Catalog) MarkShared

func (c *Catalog) MarkShared()

MarkShared records that this catalog is the process-lived copy of its configuration and registers every route schema in it with toolutil.ShareSchema, so the transforms every server derives from them are built once. Call it only from a cache that keeps the catalog for the rest of the process: a registered schema is never collected, and a catalog's identity may key other caches from then on.

func (*Catalog) SharedOrigin

func (c *Catalog) SharedOrigin() *Catalog

SharedOrigin returns the shared catalog this one is, or was bound from, and nil for a catalog that is neither. It is the identity to key a client-independent derivation on: every server of one configuration binds the same shared catalog, and a catalog nobody shared has no identity worth caching under.

func (*Catalog) Validate

func (c *Catalog) Validate() error

Validate verifies that the catalog has a consistent, executable action index.

func (*Catalog) WithSafeModePreviews

func (c *Catalog) WithSafeModePreviews() *Catalog

WithSafeModePreviews returns a cloned catalog in which every mutating action is replaced by a handler returning a toolutil.SafeModePreview naming that action, leaving read-only actions untouched.

Safe Mode is a per-action policy, but dispatcher surfaces (meta-tools and the dynamic execute tool) expose one tool covering many actions, so intercepting at the tool level would block that tool's reads too. Rewriting the catalog instead keeps the interception exactly as granular as the policy: reads execute, writes preview, and the preview names the action rather than the dispatcher. Destructive is cleared on rewritten actions because nothing is executed, so there is nothing to confirm.

type CatalogGroupSpec

type CatalogGroupSpec struct {
	ToolName               string
	Title                  string
	Description            string
	ReadOnly               bool
	Icons                  []mcp.Icon
	BaseDomain             string
	EnterpriseOnly         bool
	GitLabDotComOnly       bool
	CapabilityRequirements []string
	FormatResult           toolutil.FormatResultFunc
	Actions                []toolutil.ActionSpec
	OwnerPackage           string
	SurfaceKind            SurfaceKind
}

CatalogGroupSpec is the canonical metadata contract for one catalog group.

func CloneCatalogGroupSpec

func CloneCatalogGroupSpec(spec CatalogGroupSpec) CatalogGroupSpec

CloneCatalogGroupSpec returns a defensive copy of group metadata.

func (CatalogGroupSpec) GroupOptions

func (spec CatalogGroupSpec) GroupOptions() GroupOptions

GroupOptions returns catalog group options projected from the group spec.

func (CatalogGroupSpec) Validate

func (spec CatalogGroupSpec) Validate() error

Validate verifies group-level catalog invariants before runtime projection.

type FilterOptions

type FilterOptions struct {
	ExcludeTools     []string
	ReadOnlyOnly     bool
	AllowedToolNames []string
}

FilterOptions describes catalog-level filtering inputs.

type Group

type Group struct {
	ToolName               string
	Title                  string
	Description            string
	Icons                  []mcp.Icon
	ReadOnly               bool
	FormatResult           toolutil.FormatResultFunc
	BaseDomain             string
	EnterpriseOnly         bool
	GitLabDotComOnly       bool
	CapabilityRequirements []string
	OwnerPackage           string
	SurfaceKind            SurfaceKind
	Actions                map[string]Action
	ActionOrder            []string
}

Group describes all actions exposed through one logical meta-tool group.

func GroupFromSpecs

func GroupFromSpecs(opts GroupOptions, specs []toolutil.ActionSpec) (Group, error)

GroupFromSpecs builds a catalog group from canonical action specs.

func NewGroup

func NewGroup(opts GroupOptions) Group

NewGroup creates an action group with initialized maps.

func (*Group) ActionMap

func (g *Group) ActionMap() toolutil.ActionMap

ActionMap returns a legacy route map for this group. The map is the caller's; the routes in it share their schemas with the group, as every accessor of this package does (see Catalog).

func (*Group) ActionsInOrder

func (g *Group) ActionsInOrder() []Action

ActionsInOrder returns group actions in deterministic action-name order.

func (*Group) SetAction

func (g *Group) SetAction(action Action)

SetAction inserts or replaces an action in the group.

type GroupOptions

type GroupOptions struct {
	ToolName               string
	Title                  string
	Description            string
	Icons                  []mcp.Icon
	ReadOnly               bool
	FormatResult           toolutil.FormatResultFunc
	BaseDomain             string
	EnterpriseOnly         bool
	GitLabDotComOnly       bool
	CapabilityRequirements []string
	OwnerPackage           string
	SurfaceKind            SurfaceKind
}

GroupOptions contains metadata for creating a catalog group.

type ScopeWithheld added in v3.2.0

type ScopeWithheld struct {
	ID string
	// ByGitLab is the classic API scope GitLab requires for what the action
	// sends that the credential lacks: api, or nothing.
	ByGitLab []string
	// ByServer are the scopes the action's catalog group demands before this
	// server serves it that the credential lacks: admin_mode, or nothing.
	ByServer []string
}

ScopeWithheld is one action key a credential's scopes withheld, with the scopes it would need and does not carry, kept apart by who asks for them: GitLab, which requires a classic API scope for what the action sends, and this server, which serves a catalog group that demands a scope (admin_mode) only to a credential carrying it. The answer to a withheld action words the two apart, because only the first is GitLab's rule: GitLab serves some actions of an admin_mode group to any authenticated token, and an instance with Admin Mode off asks admin_mode of none. The key is a canonical action ID or an alias that resolved to one, as a caller may ask for either.

func (ScopeWithheld) Missing added in v3.2.0

func (w ScopeWithheld) Missing() []string

Missing is every scope the credential lacks for the action, sorted and each once, which is what a caller reauthorizes with.

type SeeAlsoResolver added in v3.2.0

type SeeAlsoResolver func(name string) (string, bool)

SeeAlsoResolver maps a name a "See also:" clause spells, an individual-surface tool name or a canonical action ID, to the identifier the active surface's entries are invoked by, reporting whether the name is known. A nil resolver leaves descriptions untouched (the individual surface, whose namespace a domain action's clause already uses, and where a canonical ID resolves as it does on every surface).

type SurfaceKind

type SurfaceKind string

SurfaceKind classifies the runtime surface represented by a catalog group.

const (
	// SurfaceKindGitLabAction identifies ordinary GitLab API actions.
	SurfaceKindGitLabAction SurfaceKind = "gitlab-action"
	// SurfaceKindMetaGroup identifies visible domain meta-tool dispatchers.
	SurfaceKindMetaGroup SurfaceKind = "meta-group"
	// SurfaceKindDynamicController identifies Dynamic controller tools.
	SurfaceKindDynamicController SurfaceKind = "dynamic-controller"
	// SurfaceKindRuntimeUtility identifies non-GitLab runtime helper tools.
	SurfaceKindRuntimeUtility SurfaceKind = "runtime-utility"
	// SurfaceKindInteractiveUtility identifies tools that require MCP elicitation.
	SurfaceKindInteractiveUtility SurfaceKind = "interactive-utility"
)

type SurfaceToolSpec

type SurfaceToolSpec struct {
	Name  string
	Title string
	// Description is this one action's own description, the text the tool
	// serves for it on the surfaces that register it as a tool.
	Description string
	// Usage is the action's Usage line, which the dynamic surface serves in
	// its find and describe results.
	//
	// It is its own field, projected as the Usage, rather than the Description
	// served again under that name. A standalone surface tool is registered
	// on meta and on individual alike and runs through the dynamic surface
	// too, so its text is served on all three, and cmd/audit_action_ids holds
	// a Usage line to that whole, folding one assembled at run time. It holds
	// a Description only where the Description is a constant: its dotted IDs
	// to the published-ID rule, and its tool names to the served-prose rule
	// everywhere but its "See also" clause. A Description assembled at run
	// time, as the flows' is from the usage handed in, is read by neither.
	// Projecting the Description as the Usage put the flows' descriptions on
	// every surface while the rule read them nowhere, and every one of them
	// named a tool two surfaces lack. The standalone specs write their text
	// once, as the Usage and as the Description both.
	//
	// A spec without one projects an action without one. The dynamic
	// surface's two controllers are the only such specs, and they are
	// registered as tools of their own and never projected into a catalog.
	Usage string
	// GroupDescription is what the group tool this action belongs to says
	// about itself, which is not the same sentence and must not be derived
	// from one action's.
	//
	// It is carried on every spec of the group and read from whichever one
	// happens to be seen first, because a group is assembled from its actions
	// and has no record of its own. Before this field existed, the group's
	// description was taken from that first action instead: `gitlab_interactive`
	// introduced itself with 1650 characters about creating an issue, while
	// also creating merge requests, projects and releases.
	GroupDescription       string
	GroupToolName          string
	BaseDomain             string
	ActionName             string
	SurfaceKind            SurfaceKind
	Route                  toolutil.ActionRoute
	Aliases                []string
	Tags                   []string
	RelatedActions         []string
	Compatibility          toolutil.CompatibilityPolicy
	Icons                  []mcp.Icon
	CapabilityRequirements []string
	FormatResult           toolutil.FormatResultFunc
	SafeModePolicy         string
	ReadOnlyPolicy         string
	OwnerPackage           string
	ReadOnly               bool
	Destructive            bool
	Idempotent             bool
	OpenWorld              bool
}

SurfaceToolSpec is the canonical metadata contract for visible MCP tools that are not ordinary GitLab API meta-tool groups.

func CloneSurfaceToolSpec

func CloneSurfaceToolSpec(spec SurfaceToolSpec) SurfaceToolSpec

CloneSurfaceToolSpec returns a defensive copy of surface tool metadata.

func (SurfaceToolSpec) ActionSpec

func (spec SurfaceToolSpec) ActionSpec() (toolutil.ActionSpec, error)

ActionSpec projects a surface tool spec into the shared ActionSpec model.

func (SurfaceToolSpec) Validate

func (spec SurfaceToolSpec) Validate() error

Validate verifies that the surface spec has enough metadata for runtime registration and catalog projection.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL