Gitpro.Github (gitpro v1.0.0)

Copy Markdown View Source

The GraphQL half: what a board is, and what is on it.

Projects v2 has no REST endpoint worth the name, so every question here is a GraphQL one, asked through the gh CLI rather than over HTTP directly. That is a deliberate trade: gh already holds the token, already refreshes it, already knows about enterprise hosts and GH_TOKEN, and a tool that is run from a checkout is being run by someone who has gh set up. The cost is a process per request, which against a network round trip is nothing.

Impure out here, pure in there

projects/1 and items/1 shell out; decode_projects/1 and decode_items/1 are pure functions over the decoded JSON. The decoders are where the shape of a project item actually lives — a card that points at an issue, at a pull request, or at nothing — so that is what the tests exercise, on captured payloads, with no token and no network.

What is fetched

The cards come with their bodies, in the same pass that fetches the rest. It is more bytes than a list needs — but opening the detail popup is then instant and works with the network gone, which is worth more on a board of a few hundred cards than the bytes are.

Errors

Nothing raises. Every call answers {:ok, value} or {:error, message}, the message being something to put in front of a person: gh not installed, not logged in, a repository that is not there. Atui.Fetch will turn a crash into an error too, but an error with a sentence in it is worth more than one with an exception in it.

Summary

Functions

A board's title and the columns of its kanban field.

Turns the board payload into a board map.

Turns one page of the items payload into Gitpro.Item structs.

Turns the projectsV2 payload into project maps, open boards first.

Every card on the board, following the cursor until there are no more.

The Projects v2 boards repo is linked to, open ones first.

Runs a GraphQL query through gh and answers its data.

Types

board()

@type board() :: %{
  id: String.t(),
  number: pos_integer(),
  title: String.t(),
  url: String.t(),
  columns: [String.t()],
  column_field: String.t() | nil
}

project()

@type project() :: %{
  id: String.t(),
  number: pos_integer(),
  title: String.t(),
  closed: boolean(),
  url: String.t()
}

Functions

board(project_id)

@spec board(String.t()) :: {:ok, board()} | {:error, String.t()}

A board's title and the columns of its kanban field.

"The kanban board" is a single-select field by convention, and by convention it is called Status — that is what the board view groups by, and what the filter's column flags are. A board that renamed it still works: the first single-select field is taken when there is no Status.

decode_board(project_id, data)

@spec decode_board(String.t(), map()) :: board()

Turns the board payload into a board map.

column_field is the name of the field the columns came from, and it is carried rather than assumed: a card has a value for every field that has one, so reading its column means knowing which of them to read.

decode_items(data, column_field \\ "Status")

@spec decode_items(map(), String.t() | nil) :: [Gitpro.Item.t()]

Turns one page of the items payload into Gitpro.Item structs.

Cards whose content came back empty are dropped: that is what a card pointing at an issue in a repository the token cannot see looks like, and a row with no title in it is worse than no row.

decode_projects(data)

@spec decode_projects(map()) :: [project()]

Turns the projectsV2 payload into project maps, open boards first.

items(project_id, column_field \\ "Status")

@spec items(String.t(), String.t() | nil) ::
  {:ok, [Gitpro.Item.t()]} | {:error, String.t()}

Every card on the board, following the cursor until there are no more.

A board of a few hundred cards is several round trips, so this is the call that belongs in an Atui.Fetch rather than in a view callback.

projects(map)

@spec projects(Gitpro.Git.repo()) :: {:ok, [project()]} | {:error, String.t()}

The Projects v2 boards repo is linked to, open ones first.

Order is the reason this sorts at all: a repository collects closed boards over the years and the one somebody wants is almost never among them, so an open board is always offered before a closed one. Within each, the board's own order is kept.

query(graphql, variables \\ [])

@spec query(String.t(), keyword()) :: {:ok, map()} | {:error, String.t()}

Runs a GraphQL query through gh and answers its data.

GraphQL answers 200 with an errors array rather than a status code, so the exit status is not enough to tell a working query from a broken one; both are checked, and either turns into a message.