Packages

GitHub context and webhook event payloads for GitHub Actions

Current section

Files

Jump to
pontil_context src pontil context.gleam
Raw

src/pontil/context.gleam

//// Context for GitHub Actions using pontil.
////
//// This provides the `Context` type built from the GitHub Actions runtime
//// environment, similar to the `context` object provided by `@actions/github`.
//// It also provides webhook payload event loading and conversion, similar to
//// the values in the `context.payload` field.
////
//// ## Basic usage
////
//// ```gleam
//// let ctx = context.new()
//// use pr <- result.try(context.load_event(
//// event_name: ctx.event_name,
//// converter: context.event_to_pull_request,
//// ))
//// let base_sha = pr.pull_request.base.sha
//// let head_sha = pr.pull_request.head.sha
//// ```
////
//// ## Context
////
//// The `new` function initializes the context from the GitHub Actions runtime
//// environment. Values missing from or malformed in the runtime environment
//// are set to empty strings (`""`), zero (`0`), or `None` as appropriate. The
//// context repo field may be updated with `set_context_repo_from_event`.
////
//// ### Event type checking
////
//// The action's event type may be tested in the context with `is_` functions
//// (`is_push`, `is_issues`, `is_pull_request`, etc.).
////
//// ## Event Data (Webhook Payload)
////
//// The `event` function reads and parses the event webhook payload as
//// a `Dict(String, Dynamic)` value from the JSON file at `GITHUB_EVENT_PATH`.
//// The `load_event` convenience function combines `event` with a converter
//// in one step.
////
//// ### Event conversion
////
//// The raw payload can be converted to typed event data with the `event_to_`
//// conversion functions (`event_to_pull_request`, `event_to_issues`, etc.).
//// Each takes an `event_name` string and the raw event data, returning
//// `Error(InvalidEventConversion)` if the event name doesn't match.
////
//// Converted event types (`PullRequestEvent`, `DeleteEvent`, etc.) contain
//// event-level fields (`action`, `sender`, `repository`) and _may_ contain
//// a detail field (`pull_request: PullRequest`) with nested data.
////
//// ### Detailed Repository
////
//// `get_repository` decodes a rich `Repository` from any event's
//// `repository` field, which contains more information than is provided by
//// the `Context.repo` field.
////
//// ### Raw field access
////
//// Every event record and its decoded member records includes a `raw` field
//// (`Dict(String, Dynamic)`) with all original fields, including those not
//// previously retrieved. Data can be retrieved with the `get_` functions
//// (`get_string`, `get_int`, `get_bool_at`, etc.). These can be used to get
//// fields that have not been included in the typed records.
import envoy
import gleam/bool
import gleam/dict.{type Dict}
import gleam/dynamic.{type Dynamic}
import gleam/dynamic/decode
import gleam/int
import gleam/json
import gleam/list
import gleam/option.{type Option}
import gleam/result
import gleam/string
import simplifile
/// Errors returned by pontil/context functions.
pub type PontilContextError {
/// The event data file cannot be loaded because GITHUB_EVENT_PATH is unset.
MissingEventPath
/// A required field is missing from the webhook event data.
MissingEventField(field: String, event_name: String)
/// The event data could not be converted to the requested type.
InvalidEventConversion(expected: String, provided: String)
/// The webhook event data could not be parsed as JSON.
EventParseError(json.DecodeError)
/// The event data file could not be read.
EventReadError(simplifile.FileError)
/// A key was not found in a `RawObject`.
RawObjectKeyMissing(key: String)
/// A key was found in a `RawObject` but the value is not the expected type.
RawObjectInvalidType(key: String, expected: String)
}
/// Returns a human-readable description of a pontil/context error.
pub fn describe_error(error: PontilContextError) -> String {
case error {
MissingEventPath ->
"The event data cannot be loaded because GITHUB_EVENT_PATH is unset"
MissingEventField(field:, event_name:) ->
"Payload for event "
<> event_name
<> " is missing a required field: "
<> field
InvalidEventConversion(expected:, provided:) ->
"Cannot convert event to " <> expected <> ": event is " <> provided
EventParseError(_) -> "Failed to parse webhook event JSON"
EventReadError(err) ->
"Failed to read event data: " <> simplifile.describe_error(err)
RawObjectKeyMissing(key:) -> "Key not found: " <> key
RawObjectInvalidType(key:, expected:) ->
"Value at key " <> key <> " is not " <> expected
}
}
/// The raw representation of a JSON object from the webhook event data.
///
/// This is a `Dict(String, Dynamic)` containing all fields from the original
/// JSON object or sub-object. Use `get_string`, `get_int`, `get_bool`,
/// `get_object`, and related helpers to extract values:
///
/// ```gleam
/// // Single field
/// context.get_string(pr.raw, "mergeable_state")
/// // -> Ok("clean")
///
/// // Nested path
/// context.get_string_at(payload, ["pull_request", "user", "login"])
/// // -> Ok("octocat")
///
/// // List of objects
/// context.get_object_list(issue.raw, "labels")
/// |> result.unwrap([])
/// |> list.filter_map(fn(label) { context.get_string(label, "name") })
/// // -> ["bug", "help wanted"]
/// ```
pub type RawObject =
Dict(String, Dynamic)
/// A simplified repository representation parsed from `GITHUB_REPOSITORY`.
pub type Repo {
Repo(
/// The full name of the repository (`"halostatue/pontil"`).
full_name: String,
/// The name of the repository (`"pontil"`).
name: String,
/// The name of the repository owner (`"owner"`).
owner: String,
)
}
/// Repository decoded from the webhook event's `repository` field.
pub type Repository {
Repository(
/// The unique identifier of the repository.
id: Int,
/// The name of the repository.
name: String,
/// The full name of the repository (`"owner/repo"`).
full_name: String,
/// The owner of the repository.
owner: GitHubUserLite,
/// Whether the repository is private.
private: Bool,
/// The URL to the repository in the GitHub interface.
html_url: String,
/// The description of the repository.
description: Option(String),
/// Whether the repository is a fork.
fork: Bool,
/// The default branch of the repository.
default_branch: String,
raw: RawObject,
)
}
/// The GitHub Actions execution context, hydrated from environment variables.
pub type Context {
Context(
/// The name of the action (`GITHUB_ACTION`).
action: String,
/// The name of the actor that triggered this event (`GITHUB_ACTOR`).
actor: String,
/// The GitHub API URL (`GITHUB_API_URL`, default `https://api.github.com`).
api_url: String,
/// The name of the event (`GITHUB_EVENT_NAME`).
event_name: String,
/// The GitHub GraphQL API URL (`GITHUB_GRAPHQL_URL`, default
/// `https://api.github.com/graphql`).
graphql_url: String,
/// The name of the job running the action (`GITHUB_JOB`).
job: String,
/// The ref name (branch, etc.) for this run (`GITHUB_REF`).
ref: String,
/// The repo for this execution context, derived from `GITHUB_REPOSITORY` or
/// may be replaced with `event_to_repository`..
repo: Option(Repo),
/// The current run attempt (`GITHUB_RUN_ATTEMPT`).
run_attempt: Int,
/// The current run ID (`GITHUB_RUN_ID`).
run_id: Int,
/// The current run number (`GITHUB_RUN_NUMBER`).
run_number: Int,
/// The GitHub server URL (`GITHUB_SERVER_URL`, default
/// `https://github.com`).
server_url: String,
/// The SHA for the commit associated with this run (`GITHUB_SHA`).
sha: String,
/// The name of the workflow file (`GITHUB_WORKFLOW`).
workflow: String,
)
}
/// Git author/committer identity from commit metadata.
pub type GitUser {
GitUser(
/// The name of the git user.
name: Option(String),
/// The email of the git user.
email: Option(String),
raw: RawObject,
)
}
/// A lightweight GitHub user account with just the login.
pub type GitHubUserLite {
GitHubUserLite(
/// The login name of the user.
login: String,
raw: RawObject,
)
}
/// The state of an issue or pull request.
pub type IssueState {
Open
Closed
}
/// A branch in a repository. Used to represent the head and base branches of
/// a pull request.
pub type Branch {
Branch(
/// The commit SHA at the tip of this branch.
sha: String,
/// The branch ref (e.g., `"main"` or `"fix-branch"`).
ref: String,
/// The label (e.g., `"owner:main"`).
label: String,
raw: RawObject,
)
}
/// A commit. This is primarily used in `push` events.
pub type Commit {
Commit(
/// The commit SHA.
id: String,
/// The commit message.
message: String,
/// The ISO 8601 timestamp.
timestamp: String,
/// The commit author.
author: GitUser,
/// The commit committer.
committer: GitUser,
raw: RawObject,
)
}
/// Pull request detail from the `pull_request` key in the event data.
pub type PullRequest {
PullRequest(
/// The pull request number.
number: Int,
/// The URl to the pull request in the GitHub interface.
html_url: String,
/// The title of the pull request.
title: String,
/// The body of the pull request.
body: Option(String),
/// The state of the pull request.
state: IssueState,
/// Whether the pull request has been merged.
merged: Option(Bool),
/// Whether the pull request is a draft pull request.
draft: Bool,
/// Whether discussion on the pull request has been locked to collaborators
/// only.
locked: Bool,
/// The SHA for the commit when this pull request has been merged. Before
/// merging, this holds the SHA of the _test_ merge commit.
///
/// - If merged as a [merge commit][mc], `merge_commit_sha` represents the
/// SHA of the merge commit.
/// - If merged via a [squash][sq], `merge_commit_sha` represents the SHA of
/// the squashed commit on the base branch.
/// - If [rebased][rb], `merge_commit_sha` represents the commit that the
/// base branch was updated to.
///
/// [mc]: https://docs.github.com/articles/about-merge-methods-on-github/
/// [sq]: https://docs.github.com/articles/about-merge-methods-on-github/#squashing-your-merge-commits
/// [rb]: https://docs.github.com/articles/about-merge-methods-on-github/#rebasing-and-merging-your-commits
merge_commit_sha: Option(String),
/// The base branch for the pull request.
base: Branch,
/// The head branch for the pull request.
head: Branch,
/// The user that opened this pull request.
user: Option(GitHubUserLite),
raw: RawObject,
)
}
/// Issue detail from the `issue` key in the event data.
pub type Issue {
Issue(
/// The issue number.
number: Int,
/// The URL to the issue in the GitHub interface.
html_url: String,
/// The title of the issue.
title: String,
/// The body of the issue.
body: Option(String),
/// The state of the issue. Not present in all webhook variants.
state: IssueState,
/// Whether discussion on the issue has been locked to collaborators only.
locked: Bool,
/// The user that opened this issue.
user: Option(GitHubUserLite),
raw: RawObject,
)
}
/// Comment detail from the `comment` key in the event data.
pub type IssueComment {
IssueComment(
/// The comment ID.
id: Int,
/// The comment body text.
body: String,
/// The user who wrote the comment.
user: Option(GitHubUserLite),
raw: RawObject,
)
}
/// Release detail from the `release` key in the event data.
pub type Release {
Release(
/// The git tag name (e.g., `"v1.0.0"`).
tag_name: String,
/// The branch, tag, or SHA the release targets.
target_commitish: String,
/// The release name.
name: Option(String),
/// Whether this is a draft release.
draft: Bool,
/// Whether this is a pre-release.
prerelease: Bool,
/// The release body/notes in markdown.
body: Option(String),
/// The release author.
author: Option(GitHubUserLite),
raw: RawObject,
)
}
/// Workflow run detail from the `workflow_run` key in the event data.
pub type WorkflowRun {
WorkflowRun(
/// The workflow run ID.
id: Int,
/// The workflow name.
name: Option(String),
/// The head branch.
head_branch: Option(String),
/// The head SHA.
head_sha: String,
/// The current status: `"queued"`, `"in_progress"`, or `"completed"`.
status: String,
/// The conclusion, if completed: `"success"`, `"failure"`,
/// `"cancelled"`, etc.
conclusion: Option(String),
/// The event that triggered this workflow.
event: String,
raw: RawObject,
)
}
/// Deployment detail from the `deployment` key in the event data.
pub type Deployment {
Deployment(
/// The deployment ID.
id: Int,
/// The ref that was deployed.
ref: String,
/// The commit SHA that was deployed.
sha: String,
/// The environment name.
environment: String,
/// The deployment description.
description: Option(String),
/// The user who triggered the deployment.
creator: Option(GitHubUserLite),
raw: RawObject,
)
}
/// A `pull_request` or `pull_request_target` webhook event.
pub type PullRequestEvent {
PullRequestEvent(
/// The event action (e.g., `"opened"`, `"closed"`, `"synchronize"`).
action: String,
/// The pull request number (top-level event field).
number: Int,
/// The pull request detail.
pull_request: PullRequest,
/// The repository where the event occurred.
repository: RawObject,
/// The user that triggered the event.
sender: GitHubUserLite,
raw: RawObject,
)
}
/// An `issues` webhook event.
pub type IssuesEvent {
IssuesEvent(
/// The event action (e.g., `"opened"`, `"edited"`, `"closed"`).
action: String,
/// The issue detail.
issue: Issue,
/// The repository where the event occurred.
repository: RawObject,
/// The user that triggered the event.
sender: GitHubUserLite,
raw: RawObject,
)
}
/// An `issue_comment` webhook event.
pub type IssueCommentEvent {
IssueCommentEvent(
/// The event action (e.g., `"created"`, `"edited"`, `"deleted"`).
action: String,
/// The comment detail.
comment: IssueComment,
/// The issue or pull request the comment belongs to.
issue: Issue,
/// The repository where the event occurred.
repository: RawObject,
/// The user that triggered the event.
sender: GitHubUserLite,
raw: RawObject,
)
}
/// A `push` webhook event.
pub type PushEvent {
PushEvent(
/// The full git ref that was pushed (e.g., `"refs/heads/main"`).
ref: String,
/// The SHA of the most recent commit on `ref` before the push.
before: String,
/// The SHA of the most recent commit on `ref` after the push.
after: String,
/// Whether this push created the ref.
created: Bool,
/// Whether this push deleted the ref.
deleted: Bool,
/// Whether this push was a force push.
forced: Bool,
/// The base ref, if applicable. Required but nullable.
base_ref: Option(String),
/// URL showing the changes in this push.
compare: String,
/// The pushed commits. Includes a maximum of 2048 commits.
commits: List(Commit),
/// The tip commit of the push. Null when a branch is deleted.
head_commit: Option(Commit),
/// The user who pushed (git identity with `name` and `email`).
pusher: GitUser,
/// The repository where the event occurred.
repository: RawObject,
/// The user that triggered the event.
sender: GitHubUserLite,
raw: RawObject,
)
}
/// A `release` webhook event.
pub type ReleaseEvent {
ReleaseEvent(
/// The event action (e.g., `"published"`, `"created"`, `"edited"`).
action: String,
/// The release detail.
release: Release,
/// The repository where the event occurred.
repository: RawObject,
/// The user that triggered the event.
sender: GitHubUserLite,
raw: RawObject,
)
}
/// A `workflow_run` webhook event.
pub type WorkflowRunEvent {
WorkflowRunEvent(
/// The event action (e.g., `"requested"`, `"completed"`).
action: String,
/// The workflow run detail.
workflow_run: WorkflowRun,
/// The repository where the event occurred.
repository: RawObject,
/// The user that triggered the event.
sender: GitHubUserLite,
raw: RawObject,
)
}
/// A `deployment` webhook event.
pub type DeploymentEvent {
DeploymentEvent(
/// The event action (e.g., `"created"`).
action: String,
/// The deployment detail.
deployment: Deployment,
/// The repository where the event occurred.
repository: RawObject,
/// The user that triggered the event.
sender: GitHubUserLite,
raw: RawObject,
)
}
/// A `create` webhook event (branch or tag created).
pub type CreateEvent {
CreateEvent(
/// The git ref (branch or tag name).
ref: String,
/// The type of ref: `"branch"` or `"tag"`.
ref_type: String,
/// The name of the default branch.
master_branch: String,
/// The repository description.
description: Option(String),
/// The repository where the event occurred.
repository: RawObject,
/// The user that triggered the event.
sender: GitHubUserLite,
raw: RawObject,
)
}
/// A `delete` webhook event (branch or tag deleted).
pub type DeleteEvent {
DeleteEvent(
/// The git ref (branch or tag name).
ref: String,
/// The type of ref: `"branch"` or `"tag"`.
ref_type: String,
/// The repository where the event occurred.
repository: RawObject,
/// The user that triggered the event.
sender: GitHubUserLite,
raw: RawObject,
)
}
/// A `status` webhook event (commit status update).
pub type StatusEvent {
StatusEvent(
/// The commit SHA.
sha: String,
/// The status state: `"pending"`, `"success"`, `"failure"`, or `"error"`.
state: String,
/// The status context (e.g., CI system name).
context: String,
/// The status description.
description: Option(String),
/// The target URL for the status.
target_url: Option(String),
/// The repository where the event occurred.
repository: RawObject,
/// The user that triggered the event.
sender: GitHubUserLite,
raw: RawObject,
)
}
/// Return a `Context` from the runtime environment.
///
/// Context should be created once and passed to functions that require it.
pub fn new() -> Context {
Context(
event_name: env(name: "GITHUB_EVENT_NAME", or: ""),
sha: env(name: "GITHUB_SHA", or: ""),
ref: env(name: "GITHUB_REF", or: ""),
workflow: env(name: "GITHUB_WORKFLOW", or: ""),
action: env(name: "GITHUB_ACTION", or: ""),
actor: env(name: "GITHUB_ACTOR", or: ""),
job: env(name: "GITHUB_JOB", or: ""),
run_attempt: env_as_int("GITHUB_RUN_ATTEMPT"),
run_number: env_as_int("GITHUB_RUN_NUMBER"),
run_id: env_as_int("GITHUB_RUN_ID"),
api_url: env(name: "GITHUB_API_URL", or: "https://api.github.com"),
server_url: env(name: "GITHUB_SERVER_URL", or: "https://github.com"),
graphql_url: env(
name: "GITHUB_GRAPHQL_URL",
or: "https://api.github.com/graphql",
),
repo: context_repo(),
)
}
/// Sets `Context.repo` from the webhook event's `repository` object.
///
/// Returns the context unchanged if the event data does not contain a valid
/// `repository` object.
pub fn set_context_repo_from_event(
context ctx: Context,
event event_data: RawObject,
) -> Context {
case dict.get(event_data, "repository") {
Ok(repo) -> {
let decoder = {
use login <- decode.subfield(["owner", "login"], decode.string)
use name <- decode.field("name", decode.string)
decode.success(#(login, name))
}
decode.run(repo, decoder)
|> result.map(fn(pair) {
let #(owner, name) = pair
Context(
..ctx,
repo: option.Some(Repo(owner:, name:, full_name: owner <> "/" <> name)),
)
})
|> result.unwrap(or: ctx)
}
Error(Nil) -> ctx
}
}
/// Returns `True` if this is a `push` event.
pub fn is_push(ctx: Context) -> Bool {
ctx.event_name == "push"
}
/// Returns `True` if this is a `pull_request` event.
pub fn is_pull_request(ctx: Context) -> Bool {
ctx.event_name == "pull_request"
}
/// Returns `True` if this is a `pull_request_target` event.
pub fn is_pull_request_target(ctx: Context) -> Bool {
ctx.event_name == "pull_request_target"
}
/// Returns `True` if this is an `issues` event.
pub fn is_issues(ctx: Context) -> Bool {
ctx.event_name == "issues"
}
/// Returns `True` if this is an `issue_comment` event.
pub fn is_issue_comment(ctx: Context) -> Bool {
ctx.event_name == "issue_comment"
}
/// Returns `True` if this is a `workflow_dispatch` event.
pub fn is_workflow_dispatch(ctx: Context) -> Bool {
ctx.event_name == "workflow_dispatch"
}
/// Returns `True` if this is a `release` event.
pub fn is_release(ctx: Context) -> Bool {
ctx.event_name == "release"
}
/// Returns `True` if this is a `workflow_run` event.
pub fn is_workflow_run(ctx: Context) -> Bool {
ctx.event_name == "workflow_run"
}
/// Returns `True` if this is a `deployment` event.
pub fn is_deployment(ctx: Context) -> Bool {
ctx.event_name == "deployment"
}
/// Returns `True` if this is a `create` event.
pub fn is_create(ctx: Context) -> Bool {
ctx.event_name == "create"
}
/// Returns `True` if this is a `delete` event.
pub fn is_delete(ctx: Context) -> Bool {
ctx.event_name == "delete"
}
/// Returns `True` if this is a `status` event.
pub fn is_status(ctx: Context) -> Bool {
ctx.event_name == "status"
}
/// Load and parse the webhook event data from `GITHUB_EVENT_PATH`.
///
/// Event should be created once and passed to functions that require it.
///
/// Converting the raw event to a typed event as soon as practical is
/// recommended, if the conversion exists.
pub fn event() -> Result(RawObject, PontilContextError) {
case envoy.get("GITHUB_EVENT_PATH") {
Ok(path) if path != "" ->
case simplifile.read(path) {
Ok(contents) ->
case
json.parse(contents, decode.dict(decode.string, decode.dynamic))
{
Ok(p) -> Ok(p)
Error(e) -> Error(EventParseError(e))
}
Error(e) -> Error(EventReadError(e))
}
_ -> Error(MissingEventPath)
}
}
/// Load, parse, and convert the webhook event in one step.
///
/// Reads the event file from `GITHUB_EVENT_PATH` and converts it to the typed
/// event type withe provided converter function.
///
/// ```gleam
/// let ctx = context.new()
/// use pr <- result.try(context.load_event(
/// event_name: ctx.event_name,
/// converter: context.event_to_pull_request,
/// ))
///
/// // Equivalent to:
/// use event <- result.try(context.event())
/// use pr <- result.try(context.event_to_pull_request(ctx.event_name, event))
/// ```
pub fn load_event(
event_name event_name: String,
converter converter: fn(String, RawObject) -> Result(a, PontilContextError),
) -> Result(a, PontilContextError) {
use ev <- result.try(event())
converter(event_name, ev)
}
/// Convert event data to a `PullRequestEvent`.
///
/// ```gleam
/// let ctx = context.new()
/// use event <- result.try(context.event())
/// use pr <- context.event_to_pull_request(ctx.event_name, event)
/// ```
///
/// Works for both `pull_request` and `pull_request_target` events.
pub fn event_to_pull_request(
event_name event_name: String,
event event_data: RawObject,
) -> Result(PullRequestEvent, PontilContextError) {
use _ <- result.try(
check_event_name(event: event_name, allowed: [
"pull_request",
"pull_request_target",
]),
)
let raw = event_data
{
use pr <- result.try(require_decoded(
raw:,
key: "pull_request",
decoder: pull_request_decoder(),
))
use action <- result.try(require_string(raw:, key: "action"))
use number <- result.try(require_int(raw:, key: "number"))
use repository <- result.try(require_decoded(
raw:,
key: "repository",
decoder: raw_decoder(),
))
use sender <- result.try(require_decoded(
raw:,
key: "sender",
decoder: github_user_lite_decoder(),
))
Ok(PullRequestEvent(
action:,
number:,
pull_request: pr,
repository:,
sender:,
raw:,
))
}
|> with_event_name("pull_request")
}
/// Convert event data to an `IssuesEvent`.
///
/// ```gleam
/// let ctx = context.new()
/// use event <- result.try(context.event())
/// use issue <- context.event_to_issues(ctx.event_name, event)
/// ```
pub fn event_to_issues(
event_name event_name: String,
event event_data: RawObject,
) -> Result(IssuesEvent, PontilContextError) {
use _ <- result.try(check_event_name(event: event_name, allowed: ["issues"]))
let raw = event_data
{
use issue <- result.try(require_decoded(
raw:,
key: "issue",
decoder: issue_decoder(),
))
use action <- result.try(require_string(raw:, key: "action"))
use repository <- result.try(require_decoded(
raw:,
key: "repository",
decoder: raw_decoder(),
))
use sender <- result.try(require_decoded(
raw:,
key: "sender",
decoder: github_user_lite_decoder(),
))
Ok(IssuesEvent(action:, issue:, repository:, sender:, raw:))
}
|> with_event_name("issues")
}
/// Convert event data to an `IssueCommentEvent`.
///
/// ```gleam
/// let ctx = context.new()
/// use event <- result.try(context.event())
/// use issue_comment <- context.event_to_issue_comment(ctx.event_name, event)
/// ```
pub fn event_to_issue_comment(
event_name event_name: String,
event event_data: RawObject,
) -> Result(IssueCommentEvent, PontilContextError) {
use _ <- result.try(
check_event_name(event: event_name, allowed: ["issue_comment"]),
)
let raw = event_data
{
use comment <- result.try(require_decoded(
raw:,
key: "comment",
decoder: issue_comment_decoder(),
))
use issue <- result.try(require_decoded(
raw:,
key: "issue",
decoder: issue_decoder(),
))
use action <- result.try(require_string(raw:, key: "action"))
use repository <- result.try(require_decoded(
raw:,
key: "repository",
decoder: raw_decoder(),
))
use sender <- result.try(require_decoded(
raw:,
key: "sender",
decoder: github_user_lite_decoder(),
))
Ok(IssueCommentEvent(action:, comment:, issue:, repository:, sender:, raw:))
}
|> with_event_name("issue_comment")
}
/// Convert event data to a `PushEvent`.
///
/// ```gleam
/// let ctx = context.new()
/// use event <- result.try(context.event())
/// use push <- context.event_to_push(ctx.event_name, event)
/// ```
pub fn event_to_push(
event_name event_name: String,
event event_data: RawObject,
) -> Result(PushEvent, PontilContextError) {
use _ <- result.try(check_event_name(event: event_name, allowed: ["push"]))
decode_push_event(event_data) |> with_event_name("push")
}
/// Convert event data to a `ReleaseEvent`.
///
/// ```gleam
/// let ctx = context.new()
/// use event <- result.try(context.event())
/// use release <- context.event_to_release(ctx.event_name, event)
/// ```
pub fn event_to_release(
event_name event_name: String,
event event_data: RawObject,
) -> Result(ReleaseEvent, PontilContextError) {
use _ <- result.try(check_event_name(event: event_name, allowed: ["release"]))
let raw = event_data
{
use release <- result.try(require_decoded(
raw:,
key: "release",
decoder: release_decoder(),
))
use action <- result.try(require_string(raw:, key: "action"))
use repository <- result.try(require_decoded(
raw:,
key: "repository",
decoder: raw_decoder(),
))
use sender <- result.try(require_decoded(
raw:,
key: "sender",
decoder: github_user_lite_decoder(),
))
Ok(ReleaseEvent(action:, release:, repository:, sender:, raw:))
}
|> with_event_name("release")
}
/// Convert event data to a `WorkflowRunEvent`.
///
/// ```gleam
/// let ctx = context.new()
/// use event <- result.try(context.event())
/// use workflow_run <- context.event_to_workflow_run(ctx.event_name, event)
/// ```
pub fn event_to_workflow_run(
event_name event_name: String,
event event_data: RawObject,
) -> Result(WorkflowRunEvent, PontilContextError) {
use _ <- result.try(
check_event_name(event: event_name, allowed: ["workflow_run"]),
)
let raw = event_data
{
use wf_run <- result.try(require_decoded(
raw:,
key: "workflow_run",
decoder: workflow_run_decoder(),
))
use action <- result.try(require_string(raw:, key: "action"))
use repository <- result.try(require_decoded(
raw:,
key: "repository",
decoder: raw_decoder(),
))
use sender <- result.try(require_decoded(
raw:,
key: "sender",
decoder: github_user_lite_decoder(),
))
Ok(WorkflowRunEvent(
action:,
workflow_run: wf_run,
repository:,
sender:,
raw:,
))
}
|> with_event_name("workflow_run")
}
/// Convert event data to a `DeploymentEvent`.
///
/// ```gleam
/// let ctx = context.new()
/// use event <- result.try(context.event())
/// use deployment <- context.event_to_deployment(ctx.event_name, event)
/// ```
pub fn event_to_deployment(
event_name event_name: String,
event event_data: RawObject,
) -> Result(DeploymentEvent, PontilContextError) {
use _ <- result.try(
check_event_name(event: event_name, allowed: ["deployment"]),
)
let raw = event_data
{
use dep <- result.try(require_decoded(
raw:,
key: "deployment",
decoder: deployment_decoder(),
))
use action <- result.try(require_string(raw:, key: "action"))
use repository <- result.try(require_decoded(
raw:,
key: "repository",
decoder: raw_decoder(),
))
use sender <- result.try(require_decoded(
raw:,
key: "sender",
decoder: github_user_lite_decoder(),
))
Ok(DeploymentEvent(action:, deployment: dep, repository:, sender:, raw:))
}
|> with_event_name("deployment")
}
/// Convert event data to a `CreateEvent`.
///
/// ```gleam
/// let ctx = context.new()
/// use event <- result.try(context.event())
/// use create <- context.event_to_create(ctx.event_name, event)
/// ```
pub fn event_to_create(
event_name event_name: String,
event event_data: RawObject,
) -> Result(CreateEvent, PontilContextError) {
use _ <- result.try(check_event_name(event: event_name, allowed: ["create"]))
decode_create_event(event_data) |> with_event_name("create")
}
/// Convert event data to a `DeleteEvent`.
///
/// ```gleam
/// let ctx = context.new()
/// use event <- result.try(context.event())
/// use delete <- context.event_to_delete(ctx.event_name, event)
/// ```
pub fn event_to_delete(
event_name event_name: String,
event event_data: RawObject,
) -> Result(DeleteEvent, PontilContextError) {
use _ <- result.try(check_event_name(event: event_name, allowed: ["delete"]))
decode_delete_event(event_data) |> with_event_name("delete")
}
/// Convert event data to a `StatusEvent`.
///
/// ```gleam
/// let ctx = context.new()
/// use event <- result.try(context.event())
/// use status <- context.event_to_status(ctx.event_name, event)
/// ```
pub fn event_to_status(
event_name event_name: String,
event event_data: RawObject,
) -> Result(StatusEvent, PontilContextError) {
use _ <- result.try(check_event_name(event: event_name, allowed: ["status"]))
decode_status_event(event_data) |> with_event_name("status")
}
/// Convert a `RawObject` into a `Repository` (usually found in an event's
/// `repository` field).
///
/// ```gleam
/// // From raw event data
/// use ev <- result.try(context.event())
/// use repo <- result.try(context.get_object(ev, "repository"))
/// use repo <- result.try(context.raw_to_repository(repo))
///
/// // From a typed event
/// use pr <- result.try(
/// context.load_event("pull_request", context.event_to_pull_request)
/// )
/// use repo <- result.try(context.raw_to_repository(pr.repository))
/// ```
pub fn raw_to_repository(
raw: RawObject,
) -> Result(Repository, PontilContextError) {
use id <- result.try(get_int(raw:, key: "id"))
use name <- result.try(get_string(raw:, key: "name"))
use full_name <- result.try(get_string(raw:, key: "full_name"))
use owner <- result.try(decode_field(
raw:,
key: "owner",
expected: "a user object",
decoder: github_user_lite_decoder(),
))
use private <- result.try(get_bool(raw:, key: "private"))
use html_url <- result.try(get_string(raw:, key: "html_url"))
use description <- result.try(case dict.get(raw, "description") {
Ok(d) ->
Ok(
decode.run(d, decode.optional(decode.string))
|> result.unwrap(option.None),
)
Error(Nil) -> Error(RawObjectKeyMissing(key: "description"))
})
use fork <- result.try(get_bool(raw:, key: "fork"))
use default_branch <- result.try(get_string(raw:, key: "default_branch"))
Ok(Repository(
id:,
name:,
full_name:,
owner:,
private:,
html_url:,
description:,
fork:,
default_branch:,
raw:,
))
}
/// Get a string value from a `RawObject` by key.
pub fn get_string(
raw raw: RawObject,
key key: String,
) -> Result(String, PontilContextError) {
decode_field(raw:, key:, expected: "a string", decoder: decode.string)
}
/// Get an int value from a `RawObject` by key.
pub fn get_int(
raw raw: RawObject,
key key: String,
) -> Result(Int, PontilContextError) {
decode_field(raw:, key:, expected: "an int", decoder: decode.int)
}
/// Get a bool value from a `RawObject` by key.
pub fn get_bool(
raw raw: RawObject,
key key: String,
) -> Result(Bool, PontilContextError) {
decode_field(raw:, key:, expected: "a bool", decoder: decode.bool)
}
/// Get a nested object from a `RawObject` by key.
pub fn get_object(
raw raw: RawObject,
key key: String,
) -> Result(RawObject, PontilContextError) {
decode_field(raw:, key:, expected: "an object", decoder: raw_decoder())
}
/// Get a `Dynamic` list from a `RawObject` by key.
pub fn get_list(
raw raw: RawObject,
key key: String,
) -> Result(List(Dynamic), PontilContextError) {
decode_field(
raw:,
key:,
expected: "a list",
decoder: decode.list(decode.dynamic),
)
}
/// Get a list of strings from a `RawObject` by key.
pub fn get_string_list(
raw raw: RawObject,
key key: String,
) -> Result(List(String), PontilContextError) {
decode_field(
raw:,
key:,
expected: "a string list",
decoder: decode.list(decode.string),
)
}
/// Get a list of objects from a `RawObject` by key.
pub fn get_object_list(
raw raw: RawObject,
key key: String,
) -> Result(List(RawObject), PontilContextError) {
decode_field(
raw:,
key:,
expected: "an object list",
decoder: decode.list(raw_decoder()),
)
}
/// Get a string value at `path` within a `RawObject`.
pub fn get_string_at(
raw raw: RawObject,
path path: List(String),
) -> Result(String, PontilContextError) {
decode_at(raw:, path:, expected: "a string", decoder: decode.string)
}
/// Get an int value at `path` within a `RawObject`.
pub fn get_int_at(
raw raw: RawObject,
path path: List(String),
) -> Result(Int, PontilContextError) {
decode_at(raw:, path:, expected: "an int", decoder: decode.int)
}
/// Get a bool value at `path` within a `RawObject`.
pub fn get_bool_at(
raw raw: RawObject,
path path: List(String),
) -> Result(Bool, PontilContextError) {
decode_at(raw:, path:, expected: "a bool", decoder: decode.bool)
}
/// Get a nested object at `path` within a `RawObject`.
pub fn get_object_at(
raw raw: RawObject,
path path: List(String),
) -> Result(RawObject, PontilContextError) {
decode_at(raw:, path:, expected: "an object", decoder: raw_decoder())
}
fn decode_field(
raw raw: RawObject,
key key: String,
expected expected: String,
decoder decoder: decode.Decoder(a),
) -> Result(a, PontilContextError) {
case dict.get(raw, key) {
Ok(d) ->
decode.run(d, decoder)
|> result.replace_error(RawObjectInvalidType(key:, expected:))
Error(Nil) -> Error(RawObjectKeyMissing(key:))
}
}
fn decode_at(
raw raw: RawObject,
path path: List(String),
expected expected: String,
decoder decoder: decode.Decoder(a),
) -> Result(a, PontilContextError) {
case path {
[] -> Error(RawObjectKeyMissing(key: ""))
[key] -> decode_field(raw:, key:, expected:, decoder:)
[key, ..rest] ->
case
decode_field(raw:, key:, expected: "an object", decoder: raw_decoder())
{
Ok(nested) ->
decode_at(raw: nested, path: rest, expected: expected, decoder:)
Error(e) -> Error(e)
}
}
}
fn context_repo() -> Option(Repo) {
case envoy.get("GITHUB_REPOSITORY") {
Ok(full) ->
case string.split_once(full, "/") {
Ok(#(owner, name)) -> option.Some(Repo(owner:, name:, full_name: full))
Error(Nil) -> option.None
}
Error(Nil) -> option.None
}
}
fn with_event_name(
result result: Result(a, PontilContextError),
name name: String,
) -> Result(a, PontilContextError) {
result.map_error(result, fn(err) {
case err {
MissingEventField(field, "") -> MissingEventField(field, name)
_ -> err
}
})
}
fn check_event_name(
event provided: String,
allowed allowed: List(String),
) -> Result(Nil, PontilContextError) {
use <- bool.guard(list.contains(allowed, provided), return: Ok(Nil))
let expected = string.join(allowed, ", ")
Error(InvalidEventConversion(expected:, provided:))
}
fn require_string(
raw raw: RawObject,
key key: String,
) -> Result(String, PontilContextError) {
case dict.get(raw, key) {
Ok(d) ->
decode.run(d, decode.string)
|> result.replace_error(MissingEventField(key, ""))
Error(Nil) -> Error(MissingEventField(key, ""))
}
}
fn require_int(
raw raw: RawObject,
key key: String,
) -> Result(Int, PontilContextError) {
case dict.get(raw, key) {
Ok(d) ->
decode.run(d, decode.int)
|> result.replace_error(MissingEventField(key, ""))
Error(Nil) -> Error(MissingEventField(key, ""))
}
}
fn require_bool(
raw raw: RawObject,
key key: String,
) -> Result(Bool, PontilContextError) {
case dict.get(raw, key) {
Ok(d) ->
decode.run(d, decode.bool)
|> result.replace_error(MissingEventField(key, ""))
Error(Nil) -> Error(MissingEventField(key, ""))
}
}
fn require_nullable_string(
raw raw: RawObject,
key key: String,
) -> Result(Option(String), PontilContextError) {
case dict.get(raw, key) {
Ok(d) ->
Ok(
decode.run(d, decode.optional(decode.string))
|> result.unwrap(option.None),
)
Error(Nil) -> Error(MissingEventField(key, ""))
}
}
fn require_nullable(
raw raw: RawObject,
key key: String,
decoder decoder: decode.Decoder(a),
) -> Result(Option(a), PontilContextError) {
case dict.get(raw, key) {
Ok(d) ->
Ok(decode.run(d, decode.optional(decoder)) |> result.unwrap(option.None))
Error(Nil) -> Error(MissingEventField(key, ""))
}
}
fn require_list(
raw raw: RawObject,
key key: String,
decoder decoder: decode.Decoder(a),
) -> Result(List(a), PontilContextError) {
case dict.get(raw, key) {
Ok(d) ->
decode.run(d, decode.list(decoder))
|> result.replace_error(MissingEventField(key, ""))
Error(Nil) -> Error(MissingEventField(key, ""))
}
}
fn require_decoded(
raw raw: RawObject,
key key: String,
decoder decoder: decode.Decoder(a),
) -> Result(a, PontilContextError) {
case dict.get(raw, key) {
Ok(d) ->
decode.run(d, decoder)
|> result.replace_error(MissingEventField(key, ""))
Error(Nil) -> Error(MissingEventField(key, ""))
}
}
fn pull_request_decoder() -> decode.Decoder(PullRequest) {
use raw <- decode.then(raw_decoder())
use number <- decode.field("number", decode.int)
use html_url <- decode.field("html_url", decode.string)
use title <- decode.field("title", decode.string)
use body <- decode.field("body", decode.optional(decode.string))
use state <- decode.field("state", issue_state_decoder())
use merged <- decode.field("merged", decode.optional(decode.bool))
use draft <- decode.optional_field("draft", False, decode.bool)
use locked <- decode.field("locked", decode.bool)
use merge_commit_sha <- decode.field(
"merge_commit_sha",
decode.optional(decode.string),
)
use base <- decode.field("base", pr_branch_decoder())
use head <- decode.field("head", pr_branch_decoder())
use user <- decode.field("user", decode.optional(github_user_lite_decoder()))
decode.success(PullRequest(
number:,
html_url:,
title:,
body:,
state:,
merged:,
draft:,
locked:,
merge_commit_sha:,
base:,
head:,
user:,
raw:,
))
}
fn issue_decoder() -> decode.Decoder(Issue) {
use raw <- decode.then(raw_decoder())
use number <- decode.field("number", decode.int)
use html_url <- decode.field("html_url", decode.string)
use title <- decode.field("title", decode.string)
use body <- decode.field("body", decode.optional(decode.string))
use state <- decode.optional_field("state", Open, issue_state_decoder())
use locked <- decode.optional_field("locked", False, decode.bool)
use user <- decode.field("user", decode.optional(github_user_lite_decoder()))
decode.success(Issue(
number:,
html_url:,
title:,
body:,
state:,
locked:,
user:,
raw:,
))
}
fn issue_comment_decoder() -> decode.Decoder(IssueComment) {
use raw <- decode.then(raw_decoder())
use id <- decode.field("id", decode.int)
use body <- decode.field("body", decode.string)
use user <- decode.field("user", decode.optional(github_user_lite_decoder()))
decode.success(IssueComment(id:, body:, user:, raw:))
}
fn release_decoder() -> decode.Decoder(Release) {
use raw <- decode.then(raw_decoder())
use tag_name <- decode.field("tag_name", decode.string)
use target_commitish <- decode.field("target_commitish", decode.string)
use name <- decode.field("name", decode.optional(decode.string))
use draft <- decode.field("draft", decode.bool)
use prerelease <- decode.field("prerelease", decode.bool)
use body <- decode.field("body", decode.optional(decode.string))
use author <- decode.field(
"author",
decode.optional(github_user_lite_decoder()),
)
decode.success(Release(
tag_name:,
target_commitish:,
name:,
draft:,
prerelease:,
body:,
author:,
raw:,
))
}
fn workflow_run_decoder() -> decode.Decoder(WorkflowRun) {
use raw <- decode.then(raw_decoder())
use id <- decode.field("id", decode.int)
use name <- decode.field("name", decode.optional(decode.string))
use head_branch <- decode.field("head_branch", decode.optional(decode.string))
use head_sha <- decode.field("head_sha", decode.string)
use status <- decode.field("status", decode.string)
use conclusion <- decode.field("conclusion", decode.optional(decode.string))
use event <- decode.field("event", decode.string)
decode.success(WorkflowRun(
id:,
name:,
head_branch:,
head_sha:,
status:,
conclusion:,
event:,
raw:,
))
}
fn deployment_decoder() -> decode.Decoder(Deployment) {
use raw <- decode.then(raw_decoder())
use id <- decode.field("id", decode.int)
use ref <- decode.field("ref", decode.string)
use sha <- decode.field("sha", decode.string)
use environment <- decode.field("environment", decode.string)
use description <- decode.field("description", decode.optional(decode.string))
use creator <- decode.field(
"creator",
decode.optional(github_user_lite_decoder()),
)
decode.success(Deployment(
id:,
ref:,
sha:,
environment:,
description:,
creator:,
raw:,
))
}
fn issue_state_decoder() -> decode.Decoder(IssueState) {
use value <- decode.then(decode.string)
case value {
"open" -> decode.success(Open)
"closed" -> decode.success(Closed)
_ -> decode.failure(Open, "IssueState")
}
}
fn pr_branch_decoder() -> decode.Decoder(Branch) {
use raw <- decode.then(raw_decoder())
use sha <- decode.field("sha", decode.string)
use ref <- decode.field("ref", decode.string)
use label <- decode.optional_field("label", "", decode.string)
decode.success(Branch(sha:, ref:, label:, raw:))
}
fn git_user_decoder() -> decode.Decoder(GitUser) {
use raw <- decode.then(raw_decoder())
use name <- decode.optional_field(
"name",
option.None,
decode.optional(decode.string),
)
use email <- decode.optional_field(
"email",
option.None,
decode.optional(decode.string),
)
decode.success(GitUser(name:, email:, raw:))
}
fn github_user_lite_decoder() -> decode.Decoder(GitHubUserLite) {
use raw <- decode.then(raw_decoder())
use login <- decode.field("login", decode.string)
decode.success(GitHubUserLite(login:, raw:))
}
fn push_commit_decoder() -> decode.Decoder(Commit) {
use raw <- decode.then(raw_decoder())
use id <- decode.optional_field("id", "", decode.string)
use message <- decode.optional_field("message", "", decode.string)
use timestamp <- decode.optional_field("timestamp", "", decode.string)
use author <- decode.optional_field(
"author",
empty_git_user(),
git_user_decoder(),
)
use committer <- decode.optional_field(
"committer",
empty_git_user(),
git_user_decoder(),
)
decode.success(Commit(id:, message:, timestamp:, author:, committer:, raw:))
}
fn decode_push_event(
event_data: RawObject,
) -> Result(PushEvent, PontilContextError) {
let raw = event_data
use ref <- result.try(require_string(raw:, key: "ref"))
use before <- result.try(require_string(raw:, key: "before"))
use after <- result.try(require_string(raw:, key: "after"))
use compare <- result.try(require_string(raw:, key: "compare"))
use created <- result.try(require_bool(raw:, key: "created"))
use deleted <- result.try(require_bool(raw:, key: "deleted"))
use forced <- result.try(require_bool(raw:, key: "forced"))
use base_ref <- result.try(require_nullable_string(raw:, key: "base_ref"))
use commits <- result.try(require_list(
raw:,
key: "commits",
decoder: push_commit_decoder(),
))
use head_commit <- result.try(require_nullable(
raw:,
key: "head_commit",
decoder: push_commit_decoder(),
))
use pusher <- result.try(require_decoded(
raw:,
key: "pusher",
decoder: git_user_decoder(),
))
use repository <- result.try(require_decoded(
raw:,
key: "repository",
decoder: raw_decoder(),
))
use sender <- result.try(require_decoded(
raw:,
key: "sender",
decoder: github_user_lite_decoder(),
))
Ok(PushEvent(
ref:,
before:,
after:,
created:,
deleted:,
forced:,
base_ref:,
compare:,
commits:,
head_commit:,
pusher:,
repository:,
sender:,
raw:,
))
}
fn decode_create_event(
event_data: RawObject,
) -> Result(CreateEvent, PontilContextError) {
let raw = event_data
use ref <- result.try(require_string(raw:, key: "ref"))
use ref_type <- result.try(require_string(raw:, key: "ref_type"))
use master_branch <- result.try(require_string(raw:, key: "master_branch"))
use description <- result.try(require_nullable_string(
raw:,
key: "description",
))
use repository <- result.try(require_decoded(
raw:,
key: "repository",
decoder: raw_decoder(),
))
use sender <- result.try(require_decoded(
raw:,
key: "sender",
decoder: github_user_lite_decoder(),
))
Ok(CreateEvent(
ref:,
ref_type:,
master_branch:,
description:,
repository:,
sender:,
raw:,
))
}
fn decode_delete_event(
event_data: RawObject,
) -> Result(DeleteEvent, PontilContextError) {
let raw = event_data
use ref <- result.try(require_string(raw:, key: "ref"))
use ref_type <- result.try(require_string(raw:, key: "ref_type"))
use repository <- result.try(require_decoded(
raw:,
key: "repository",
decoder: raw_decoder(),
))
use sender <- result.try(require_decoded(
raw:,
key: "sender",
decoder: github_user_lite_decoder(),
))
Ok(DeleteEvent(ref:, ref_type:, repository:, sender:, raw:))
}
fn decode_status_event(
event_data: RawObject,
) -> Result(StatusEvent, PontilContextError) {
let raw = event_data
use sha <- result.try(require_string(raw:, key: "sha"))
use state <- result.try(require_string(raw:, key: "state"))
use ctx_name <- result.try(require_string(raw:, key: "context"))
use description <- result.try(require_nullable_string(
raw:,
key: "description",
))
use target_url <- result.try(require_nullable_string(raw:, key: "target_url"))
use repository <- result.try(require_decoded(
raw:,
key: "repository",
decoder: raw_decoder(),
))
use sender <- result.try(require_decoded(
raw:,
key: "sender",
decoder: github_user_lite_decoder(),
))
Ok(StatusEvent(
sha:,
state:,
context: ctx_name,
description:,
target_url:,
repository:,
sender:,
raw:,
))
}
fn empty_git_user() -> GitUser {
GitUser(name: option.None, email: option.None, raw: dict.new())
}
fn raw_decoder() -> decode.Decoder(RawObject) {
decode.dict(decode.string, decode.dynamic)
}
fn env(name name: String, or default: String) -> String {
case envoy.get(name) {
Ok(value) if value != "" -> value
_ -> default
}
}
fn env_as_int(name: String) -> Int {
envoy.get(name)
|> result.try(int.parse)
|> result.unwrap(0)
}