diff --git a/share-api.cabal b/share-api.cabal index 099034bb..d9698821 100644 --- a/share-api.cabal +++ b/share-api.cabal @@ -1,6 +1,6 @@ cabal-version: 1.12 --- This file has been generated from package.yaml by hpack version 0.37.0. +-- This file has been generated from package.yaml by hpack version 0.38.1. -- -- see: https://github.com/sol/hpack @@ -40,6 +40,7 @@ library Share.BackgroundJobs.Search.DefinitionSync Share.BackgroundJobs.Search.DefinitionSync.Types Share.BackgroundJobs.Webhooks.Queries + Share.BackgroundJobs.Webhooks.Types Share.BackgroundJobs.Webhooks.Worker Share.BackgroundJobs.Workers Share.Branch @@ -188,6 +189,8 @@ library Share.Web.Share.Tickets.Types Share.Web.Share.Types Share.Web.Share.Users.API + Share.Web.Share.Webhooks.API + Share.Web.Share.Webhooks.Impl Share.Web.Support.API Share.Web.Support.Impl Share.Web.Support.Types diff --git a/src/Share/BackgroundJobs/Webhooks/Types.hs b/src/Share/BackgroundJobs/Webhooks/Types.hs new file mode 100644 index 00000000..6f8387df --- /dev/null +++ b/src/Share/BackgroundJobs/Webhooks/Types.hs @@ -0,0 +1,146 @@ +{-# LANGUAGE StandaloneDeriving #-} + +module Share.BackgroundJobs.Webhooks.Types + ( WebhookSendFailure (..), + WebhookEventPayload (..), + ) +where + +import Control.Lens hiding ((.=)) +import Crypto.JWT (JWTError) +import Data.Aeson (FromJSON (..), ToJSON (..)) +import Data.Aeson qualified as Aeson +import Data.ByteString.Lazy.Char8 qualified as BL +import Data.Text qualified as Text +import Data.Text.Encoding qualified as Text +import Data.Time (UTCTime) +import Network.HTTP.Types qualified as HTTP +import Servant.Server +import Share.IDs +import Share.JWT (JWTParam (..)) +import Share.JWT qualified as JWT +import Share.Notifications.Types +import Share.Notifications.Webhooks.Secrets (WebhookSecretError) +import Share.Prelude +import Share.Utils.Logging qualified as Logging +import Share.Web.Errors +import UnliftIO qualified + +data WebhookSendFailure + = ReceiverError NotificationEventId NotificationWebhookId HTTP.Status BL.ByteString + | InvalidRequest NotificationEventId NotificationWebhookId UnliftIO.SomeException + | WebhookSecretFetchError NotificationEventId NotificationWebhookId WebhookSecretError + | JWTError NotificationEventId NotificationWebhookId JWTError + deriving stock (Show) + +instance ToServerError WebhookSendFailure where + toServerError = \case + ReceiverError _eventId _webhookId status _body -> + ( ErrorID "webhook:receiver-error", + err500 + { errBody = + BL.fromStrict $ + Text.encodeUtf8 $ + "Webhook receiver returned error status: " + <> Text.pack (show status) + } + ) + InvalidRequest _eventId _webhookId _err -> + ( ErrorID "webhook:invalid-request", + err400 + { errBody = + BL.fromStrict $ + Text.encodeUtf8 $ + "Invalid webhook request." + } + ) + WebhookSecretFetchError _eventId _webhookId _err -> + ( ErrorID "webhook:secret-fetch-error", + err500 + { errBody = + BL.fromStrict $ + Text.encodeUtf8 $ + "Failed to fetch webhook secret." + } + ) + JWTError _eventId _webhookId _err -> + ( ErrorID "webhook:jwt-error", + err500 + { errBody = + BL.fromStrict $ + Text.encodeUtf8 $ + "Failed to generate or verify JWT." + } + ) + +instance Logging.Loggable WebhookSendFailure where + toLog = \case + ReceiverError eventId webhookId status body -> + Logging.textLog + ( "Webhook receiver error: " + <> Text.pack (show status) + <> " " + <> Text.decodeUtf8 (BL.toStrict body) + ) + & Logging.withTag ("status", tShow status) + & Logging.withTag ("event_id", tShow eventId) + & Logging.withTag ("webhook_id", tShow webhookId) + & Logging.withSeverity Logging.UserFault + InvalidRequest eventId webhookId err -> + Logging.textLog ("Invalid request: " <> Text.pack (show err)) + & Logging.withTag ("event_id", tShow eventId) + & Logging.withTag ("webhook_id", tShow webhookId) + & Logging.withSeverity Logging.UserFault + WebhookSecretFetchError eventId webhookId err -> + Logging.textLog ("Failed to fetch webhook secret: " <> Text.pack (show err)) + & Logging.withTag ("event_id", tShow eventId) + & Logging.withTag ("webhook_id", tShow webhookId) + & Logging.withSeverity Logging.Error + JWTError eventId webhookId err -> + Logging.textLog ("JWT error: " <> Text.pack (show err)) + & Logging.withTag ("event_id", tShow eventId) + & Logging.withTag ("webhook_id", tShow webhookId) + & Logging.withSeverity Logging.Error + +data WebhookEventPayload jwt = WebhookEventPayload + { -- | The event ID of the notification event. + eventId :: NotificationEventId, + -- | The time at which the event occurred. + occurredAt :: UTCTime, + -- | The topic of the notification event. + topic :: NotificationTopic, + -- | The data associated with the notification event. + data_ :: HydratedEvent, + -- | A signed token containing all of the same data. + jwt :: jwt + } + deriving stock (Show, Eq) + +deriving via JWT.JSONJWTClaims (WebhookEventPayload ()) instance JWT.AsJWTClaims (WebhookEventPayload ()) + +instance ToJSON (WebhookEventPayload JWTParam) where + toJSON WebhookEventPayload {eventId, occurredAt, topic, data_, jwt} = + Aeson.object + [ "eventId" Aeson..= eventId, + "occurredAt" Aeson..= occurredAt, + "topic" Aeson..= topic, + "data" Aeson..= data_, + "signed" Aeson..= jwt + ] + +instance ToJSON (WebhookEventPayload ()) where + toJSON WebhookEventPayload {eventId, occurredAt, topic, data_} = + Aeson.object + [ "eventId" Aeson..= eventId, + "occurredAt" Aeson..= occurredAt, + "topic" Aeson..= topic, + "data" Aeson..= data_ + ] + +instance FromJSON (WebhookEventPayload ()) where + parseJSON = Aeson.withObject "WebhookEventPayload" $ \o -> do + eventId <- o Aeson..: "eventId" + occurredAt <- o Aeson..: "occurredAt" + topic <- o Aeson..: "topic" + data_ <- o Aeson..: "data" + pure WebhookEventPayload {eventId, occurredAt, topic, data_, jwt = ()} diff --git a/src/Share/BackgroundJobs/Webhooks/Worker.hs b/src/Share/BackgroundJobs/Webhooks/Worker.hs index fea66b16..409d6aee 100644 --- a/src/Share/BackgroundJobs/Webhooks/Worker.hs +++ b/src/Share/BackgroundJobs/Webhooks/Worker.hs @@ -8,13 +8,10 @@ module Share.BackgroundJobs.Webhooks.Worker (worker) where import Control.Lens hiding ((.=)) import Control.Monad.Except (ExceptT (..), runExceptT) -import Crypto.JWT (JWTError) -import Data.Aeson (FromJSON (..), ToJSON (..)) +import Data.Aeson (ToJSON (..)) import Data.Aeson qualified as Aeson -import Data.ByteString.Lazy.Char8 qualified as BL import Data.List.Extra qualified as List import Data.Text qualified as Text -import Data.Text.Encoding qualified as Text import Data.Time (UTCTime) import Ki.Unlifted qualified as Ki import Network.HTTP.Client qualified as HTTPClient @@ -24,6 +21,7 @@ import Network.URI qualified as URI import Share.BackgroundJobs.Errors (reportError) import Share.BackgroundJobs.Monad (Background) import Share.BackgroundJobs.Webhooks.Queries qualified as WQ +import Share.BackgroundJobs.Webhooks.Types import Share.BackgroundJobs.Workers (newWorker) import Share.ChatApps (Author (..)) import Share.ChatApps qualified as ChatApps @@ -37,7 +35,7 @@ import Share.Metrics qualified as Metrics import Share.Notifications.Ops qualified as NotOps import Share.Notifications.Queries qualified as NQ import Share.Notifications.Types -import Share.Notifications.Webhooks.Secrets (WebhookConfig (..), WebhookSecretError) +import Share.Notifications.Webhooks.Secrets (WebhookConfig (..)) import Share.Notifications.Webhooks.Secrets qualified as Webhooks import Share.Postgres qualified as PG import Share.Postgres.Notifications qualified as Notif @@ -53,42 +51,6 @@ import Share.Web.Share.DisplayInfo.Types qualified as DisplayInfo import Share.Web.UI.Links qualified as Links import UnliftIO qualified -data WebhookSendFailure - = ReceiverError NotificationEventId NotificationWebhookId HTTP.Status BL.ByteString - | InvalidRequest NotificationEventId NotificationWebhookId UnliftIO.SomeException - | WebhookSecretFetchError NotificationEventId NotificationWebhookId WebhookSecretError - | JWTError NotificationEventId NotificationWebhookId JWTError - deriving stock (Show) - -instance Logging.Loggable WebhookSendFailure where - toLog = \case - ReceiverError eventId webhookId status body -> - Logging.textLog - ( "Webhook receiver error: " - <> Text.pack (show status) - <> " " - <> Text.decodeUtf8 (BL.toStrict body) - ) - & Logging.withTag ("status", tShow status) - & Logging.withTag ("event_id", tShow eventId) - & Logging.withTag ("webhook_id", tShow webhookId) - & Logging.withSeverity Logging.UserFault - InvalidRequest eventId webhookId err -> - Logging.textLog ("Invalid request: " <> Text.pack (show err)) - & Logging.withTag ("event_id", tShow eventId) - & Logging.withTag ("webhook_id", tShow webhookId) - & Logging.withSeverity Logging.UserFault - WebhookSecretFetchError eventId webhookId err -> - Logging.textLog ("Failed to fetch webhook secret: " <> Text.pack (show err)) - & Logging.withTag ("event_id", tShow eventId) - & Logging.withTag ("webhook_id", tShow webhookId) - & Logging.withSeverity Logging.Error - JWTError eventId webhookId err -> - Logging.textLog ("JWT error: " <> Text.pack (show err)) - & Logging.withTag ("event_id", tShow eventId) - & Logging.withTag ("webhook_id", tShow webhookId) - & Logging.withSeverity Logging.Error - -- | Check every 10 minutes if we haven't heard on the notifications channel. -- Just in case we missed a notification. maxPollingIntervalSeconds :: Int @@ -140,49 +102,6 @@ processWebhook authZReceipt = withSpan "background:webhooks:process-webhook" mem webhookTimeout :: HTTPClient.ResponseTimeout webhookTimeout = HTTPClient.responseTimeoutMicro (20 * 1000000 {- 20 seconds -}) -data WebhookEventPayload jwt = WebhookEventPayload - { -- | The event ID of the notification event. - eventId :: NotificationEventId, - -- | The time at which the event occurred. - occurredAt :: UTCTime, - -- | The topic of the notification event. - topic :: NotificationTopic, - -- | The data associated with the notification event. - data_ :: HydratedEvent, - -- | A signed token containing all of the same data. - jwt :: jwt - } - deriving stock (Show, Eq) - -deriving via JWT.JSONJWTClaims (WebhookEventPayload ()) instance JWT.AsJWTClaims (WebhookEventPayload ()) - -instance ToJSON (WebhookEventPayload JWTParam) where - toJSON WebhookEventPayload {eventId, occurredAt, topic, data_, jwt} = - Aeson.object - [ "eventId" Aeson..= eventId, - "occurredAt" Aeson..= occurredAt, - "topic" Aeson..= topic, - "data" Aeson..= data_, - "signed" Aeson..= jwt - ] - -instance ToJSON (WebhookEventPayload ()) where - toJSON WebhookEventPayload {eventId, occurredAt, topic, data_} = - Aeson.object - [ "eventId" Aeson..= eventId, - "occurredAt" Aeson..= occurredAt, - "topic" Aeson..= topic, - "data" Aeson..= data_ - ] - -instance FromJSON (WebhookEventPayload ()) where - parseJSON = Aeson.withObject "WebhookEventPayload" $ \o -> do - eventId <- o Aeson..: "eventId" - occurredAt <- o Aeson..: "occurredAt" - topic <- o Aeson..: "topic" - data_ <- o Aeson..: "data" - pure WebhookEventPayload {eventId, occurredAt, topic, data_, jwt = ()} - tryWebhook :: NotificationEvent NotificationEventId UnifiedDisplayInfo UTCTime HydratedEvent -> NotificationWebhookId -> diff --git a/src/Share/Notifications/Types.hs b/src/Share/Notifications/Types.hs index 564a9a1c..c20a0c0c 100644 --- a/src/Share/Notifications/Types.hs +++ b/src/Share/Notifications/Types.hs @@ -71,7 +71,7 @@ data NotificationTopic | ProjectTicketStatusUpdated | ProjectTicketComment | ProjectReleaseCreated - deriving (Eq, Show, Ord) + deriving (Eq, Show, Ord, Enum, Bounded) instance PG.EncodeValue NotificationTopic where encodeValue = HasqlEncoders.enum \case diff --git a/src/Share/Notifications/Webhooks/Secrets.hs b/src/Share/Notifications/Webhooks/Secrets.hs index 79fd3ebb..d00f03ba 100644 --- a/src/Share/Notifications/Webhooks/Secrets.hs +++ b/src/Share/Notifications/Webhooks/Secrets.hs @@ -19,7 +19,7 @@ import Servant.Client qualified as ServantClient import Servant.Server (err500) import Share.App (AppM) import Share.Env qualified as Env -import Share.IDs (NotificationWebhookId) +import Share.IDs import Share.IDs qualified as IDs import Share.Prelude import Share.Utils.Logging qualified as Logging diff --git a/src/Share/Web/API.hs b/src/Share/Web/API.hs index b2f94dae..2c7816b3 100644 --- a/src/Share/Web/API.hs +++ b/src/Share/Web/API.hs @@ -14,6 +14,7 @@ import Share.Web.Share.API qualified as Share import Share.Web.Share.Orgs.API qualified as Orgs import Share.Web.Share.Projects.API qualified as Projects import Share.Web.Share.Users.API qualified as Users +import Share.Web.Share.Webhooks.API qualified as Webhooks import Share.Web.Support.API qualified as Support import Share.Web.Types import Share.Web.UCM.SyncV2.API qualified as SyncV2 @@ -55,6 +56,7 @@ type API = :<|> ("ucm" :> "v1" :> "projects" :> MaybeAuthenticatedSession :> UCMProjects.ProjectsAPI) :<|> ("ucm" :> "v2" :> "sync" :> MaybeAuthenticatedUserId :> SyncV2.API) :<|> ("admin" :> Admin.API) + :<|> ("webhooks" :> Webhooks.API) api :: Proxy API api = Proxy @API diff --git a/src/Share/Web/Impl.hs b/src/Share/Web/Impl.hs index 9d8156be..e8712bb4 100644 --- a/src/Share/Web/Impl.hs +++ b/src/Share/Web/Impl.hs @@ -24,6 +24,7 @@ import Share.Web.OAuth.Impl qualified as OAuth import Share.Web.Share.Impl qualified as Share import Share.Web.Share.Orgs.Impl qualified as Orgs import Share.Web.Share.Projects.Impl qualified as Projects +import Share.Web.Share.Webhooks.Impl qualified as Webhooks import Share.Web.Support.Impl qualified as Support import Share.Web.Types import Share.Web.UCM.Projects.Impl qualified as UCMProjects @@ -91,3 +92,4 @@ server = :<|> UCMProjects.server :<|> SyncV2.server :<|> Admin.server + :<|> Webhooks.server diff --git a/src/Share/Web/Share/Webhooks/API.hs b/src/Share/Web/Share/Webhooks/API.hs new file mode 100644 index 00000000..98c3e5b5 --- /dev/null +++ b/src/Share/Web/Share/Webhooks/API.hs @@ -0,0 +1,27 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE TypeOperators #-} + +module Share.Web.Share.Webhooks.API + ( API, + Routes (..), + WebhookPayloadExamples, + ) +where + +import Servant +import Share.BackgroundJobs.Webhooks.Types +import Share.JWT +import Share.Prelude + +type API = NamedRoutes Routes + +data Routes mode + = Routes + { payloadExamples :: mode :- "examples" :> WebhookExamplesEndpoint + } + deriving stock (Generic) + +type WebhookPayloadExamples = [WebhookEventPayload JWTParam] + +type WebhookExamplesEndpoint = + Get '[JSON] WebhookPayloadExamples diff --git a/src/Share/Web/Share/Webhooks/Impl.hs b/src/Share/Web/Share/Webhooks/Impl.hs new file mode 100644 index 00000000..73d2ed41 --- /dev/null +++ b/src/Share/Web/Share/Webhooks/Impl.hs @@ -0,0 +1,198 @@ +module Share.Web.Share.Webhooks.Impl (server) where + +import Data.Time (getCurrentTime) +import Data.UUID qualified as UUID +import Servant +import Share.BackgroundJobs.Webhooks.Types +import Share.Contribution +import Share.Env qualified as Env +import Share.IDs +import Share.JWT +import Share.JWT qualified as JWT +import Share.Notifications.Types +import Share.Prelude +import Share.Ticket +import Share.Ticket qualified as Ticket +import Share.Web.App +import Share.Web.Errors (respondError) +import Share.Web.Share.DisplayInfo.Types (UserDisplayInfo (..)) +import Share.Web.Share.Webhooks.API (WebhookPayloadExamples) +import Share.Web.Share.Webhooks.API qualified as WebhooksAPI +import Share.Web.UI.Links qualified as Links + +server :: ServerT WebhooksAPI.API WebApp +server = + WebhooksAPI.Routes + { WebhooksAPI.payloadExamples = exampleEndpoint + } + +exampleEndpoint :: WebApp WebhookPayloadExamples +exampleEndpoint = do + let eventId = NotificationEventId UUID.nil + let projectId = ProjectId UUID.nil + let projectSlug = ProjectSlug "example-project" + let userHandle = UserHandle "example-user" + let projectShortHand = ProjectShortHand {userHandle, projectSlug} + let projectOwnerHandle = UserHandle "project-owner" + let projectOwnerUserId = UserId UUID.nil + let branchId = BranchId UUID.nil + let branchName = BranchName "main" + let contributorHandle = UserHandle "contributor" + let projectBranchShortHand = + ProjectBranchShortHand + { userHandle, + projectSlug, + contributorHandle = Just contributorHandle, + branchName + } + let branchContributorUserId = Just (UserId UUID.nil) + let contributorHandle = UserHandle "branch-contributor" + let branchShortHand = BranchShortHand {contributorHandle = Just contributorHandle, branchName} + now <- liftIO getCurrentTime + let projectPayload = + ProjectPayload + { projectId, + projectSlug, + projectShortHand, + projectOwnerHandle, + projectOwnerUserId + } + let branchPayload = + BranchPayload + { branchId, + branchName, + branchShortHand, + projectBranchShortHand, + branchContributorUserId, + branchContributorHandle = Just contributorHandle + } + let contributionBranchName = BranchName "contribution-branch" + let contributionProjectBranchShortHand = + ProjectBranchShortHand + { userHandle, + projectSlug, + contributorHandle = Just contributorHandle, + branchName = contributionBranchName + } + let contributionBranchPayload = + BranchPayload + { branchId, + branchName = contributionBranchName, + branchShortHand, + projectBranchShortHand = contributionProjectBranchShortHand, + branchContributorUserId = Just (UserId UUID.nil), + branchContributorHandle = Just contributorHandle + } + let contributionId = ContributionId UUID.nil + let contributionNumber = ContributionNumber 1 + let contributionTitle = "Add new feature" + let contributionDescription = Just "This contribution adds a new feature." + let contributionStatus = InReview + let contributionAuthor = + UserDisplayInfo + { handle = contributorHandle, + name = Just "Branch Contributor", + avatarUrl = Nothing, + userId = UserId UUID.nil + } + let contributionPayload = + ContributionPayload + { contributionId, + contributionNumber, + contributionTitle, + contributionDescription, + contributionStatus, + contributionAuthor, + contributionSourceBranch = contributionBranchPayload, + contributionTargetBranch = branchPayload, + contributionCreatedAt = now + } + let contributionStatusUpdatePayload = + StatusUpdatePayload {oldStatus = Draft, newStatus = InReview} + let ticketStatusUpdatePayload = + StatusUpdatePayload {oldStatus = Open, newStatus = Ticket.Closed} + let commentId = CommentId UUID.nil + let commentContent = "This is a comment on the contribution." + let userDisplayInfo = + UserDisplayInfo + { handle = userHandle, + name = Just "User Name", + avatarUrl = Nothing, + userId = UserId UUID.nil + } + let commentPayload = + CommentPayload + { commentId, + commentContent, + commentCreatedAt = now, + commentAuthor = userDisplayInfo + } + let ticketPayload = + TicketPayload + { ticketId = TicketId UUID.nil, + ticketNumber = TicketNumber 1, + ticketTitle = "Bug report", + ticketDescription = Just "There is a bug in the system.", + ticketStatus = Open, + ticketAuthor = userDisplayInfo, + ticketCreatedAt = now + } + let releaseVersion = ReleaseVersion {major = 1, minor = 2, patch = 3} + let releasePayload = + ReleasePayload + { releaseId = ReleaseId UUID.nil, + releaseVersion, + releaseCreatedAt = now + } + let allTopics :: [NotificationTopic] = [minBound .. maxBound] + let allPayloads :: [(NotificationTopic, HydratedEventPayload)] = + allTopics + <&> \case + ProjectBranchUpdated -> HydratedProjectBranchUpdatedPayload projectPayload branchPayload + ProjectContributionCreated -> + HydratedProjectContributionCreatedPayload projectPayload contributionPayload + ProjectContributionStatusUpdated -> + HydratedProjectContributionStatusUpdatedPayload projectPayload contributionPayload contributionStatusUpdatePayload + ProjectContributionComment -> + HydratedProjectContributionCommentPayload + projectPayload + contributionPayload + commentPayload + ProjectTicketCreated -> + HydratedProjectTicketCreatedPayload projectPayload ticketPayload + ProjectTicketStatusUpdated -> + HydratedProjectTicketStatusUpdatedPayload + projectPayload + ticketPayload + ticketStatusUpdatePayload + ProjectTicketComment -> + HydratedProjectTicketCommentPayload + projectPayload + ticketPayload + commentPayload + ProjectReleaseCreated -> + HydratedProjectReleaseCreatedPayload projectPayload releasePayload + & zip allTopics + + jwtSettings <- asks Env.jwtSettings + examples <- + for allPayloads \(topic, hydratedEventPayload) -> do + hydratedEventLink <- Links.notificationLink hydratedEventPayload + let eventPayload = + WebhookEventPayload + { eventId, + occurredAt = now, + topic, + data_ = + HydratedEvent + { hydratedEventPayload, + hydratedEventLink + }, + jwt = () + } + jwt <- + JWT.signJWT jwtSettings eventPayload >>= \case + Left jwtErr -> respondError $ JWTError eventId (NotificationWebhookId UUID.nil) jwtErr + Right jwt -> pure $ JWTParam jwt + pure $ eventPayload {jwt} + pure examples diff --git a/transcripts/run-transcripts.zsh b/transcripts/run-transcripts.zsh index 245840f7..f1999c60 100755 --- a/transcripts/run-transcripts.zsh +++ b/transcripts/run-transcripts.zsh @@ -12,32 +12,18 @@ done; source "$(realpath "$(dirname "$0")")/transcript_helpers.sh" -typeset -A transcripts -transcripts=( - contribution-merge transcripts/share-apis/contribution-merge/ - search transcripts/share-apis/search/ - users transcripts/share-apis/users/ - user-creation transcripts/share-apis/user-creation/ - contribution-diffs transcripts/share-apis/contribution-diffs/ - definition-diffs transcripts/share-apis/definition-diffs/ - tickets transcripts/share-apis/tickets/ - contributions transcripts/share-apis/contributions/ - projects-flow transcripts/share-apis/projects-flow/ - project-maintainers transcripts/share-apis/project-maintainers/ - release transcripts/share-apis/releases/ - branche transcripts/share-apis/branches/ - branch-browse transcripts/share-apis/branch-browse/ - code-browse transcripts/share-apis/code-browse/ - sync-apis transcripts/sync-apis/ - orgs transcripts/share-apis/orgs/ - notifications transcripts/share-apis/notifications/ -) +# Base directory containing all transcripts +transcripts_location="transcripts/share-apis" -for transcript dir in "${(@kv)transcripts}"; do +# Find all directories within transcripts_location +for dir in "$transcripts_location"/*(/); do + # Extract the directory name (transcript name) + transcript="${dir:t}" + # If the first argument is missing, run all transcripts, otherwise run only transcripts which match a prefix of the argument if [ -z "${1:-}" ] || [[ "$transcript" == "$1"* ]]; then pg_reset_fixtures echo "Running transcript $transcript" - (cd "$dir" && {rm -f ./*.json(N) || true} && ./run.zsh); + (cd "$dir" && {rm -f ./*.json(N) || true} && ./run.zsh) fi done diff --git a/transcripts/share-apis/webhooks/run.zsh b/transcripts/share-apis/webhooks/run.zsh new file mode 100755 index 00000000..066b2c85 --- /dev/null +++ b/transcripts/share-apis/webhooks/run.zsh @@ -0,0 +1,8 @@ +#!/usr/bin/env zsh + +set -e + +source "../../transcript_helpers.sh" + +# Webhook, payload examples +fetch "$transcripts_user" GET webhook-examples '/webhooks/examples' diff --git a/transcripts/share-apis/webhooks/webhook-examples.json b/transcripts/share-apis/webhooks/webhook-examples.json new file mode 100644 index 00000000..0947d37a --- /dev/null +++ b/transcripts/share-apis/webhooks/webhook-examples.json @@ -0,0 +1,333 @@ +{ + "body": [ + { + "data": { + "kind": "project:branch:updated", + "link": "http://:1234/@example-user/example-project/code/contributor/main/latest", + "payload": { + "branch": { + "branchContributorHandle": "branch-contributor", + "branchContributorUserId": "U-", + "branchId": "B-", + "branchName": "main", + "branchShortHand": "@branch-contributor/main", + "projectBranchShortHand": "@example-user/example-project/@contributor/main" + }, + "project": { + "projectId": "P-", + "projectOwnerHandle": "project-owner", + "projectOwnerUserId": "U-", + "projectShortHand": "@example-user/example-project", + "projectSlug": "example-project" + } + } + }, + "eventId": "EVENT-", + "occurredAt": "", + "signed": "", + "topic": "project:branch:updated" + }, + { + "data": { + "kind": "project:contribution:created", + "link": "http://:1234/@example-user/example-project/contributions/1", + "payload": { + "contribution": { + "author": { + "avatarUrl": null, + "handle": "branch-contributor", + "name": "Branch Contributor", + "userId": "U-" + }, + "contributionId": "C-", + "description": "This contribution adds a new feature.", + "number": 1, + "sourceBranch": { + "branchContributorHandle": "branch-contributor", + "branchContributorUserId": "U-", + "branchId": "B-", + "branchName": "contribution-branch", + "branchShortHand": "@branch-contributor/main", + "projectBranchShortHand": "@example-user/example-project/@branch-contributor/contribution-branch" + }, + "status": "in_review", + "targetBranch": { + "branchContributorHandle": "branch-contributor", + "branchContributorUserId": "U-", + "branchId": "B-", + "branchName": "main", + "branchShortHand": "@branch-contributor/main", + "projectBranchShortHand": "@example-user/example-project/@contributor/main" + }, + "title": "Add new feature" + }, + "project": { + "projectId": "P-", + "projectOwnerHandle": "project-owner", + "projectOwnerUserId": "U-", + "projectShortHand": "@example-user/example-project", + "projectSlug": "example-project" + } + } + }, + "eventId": "EVENT-", + "occurredAt": "", + "signed": "", + "topic": "project:contribution:created" + }, + { + "data": { + "kind": "project:contribution:updated", + "link": "http://:1234/@example-user/example-project/contributions/1", + "payload": { + "contribution": { + "author": { + "avatarUrl": null, + "handle": "branch-contributor", + "name": "Branch Contributor", + "userId": "U-" + }, + "contributionId": "C-", + "description": "This contribution adds a new feature.", + "number": 1, + "sourceBranch": { + "branchContributorHandle": "branch-contributor", + "branchContributorUserId": "U-", + "branchId": "B-", + "branchName": "contribution-branch", + "branchShortHand": "@branch-contributor/main", + "projectBranchShortHand": "@example-user/example-project/@branch-contributor/contribution-branch" + }, + "status": "in_review", + "targetBranch": { + "branchContributorHandle": "branch-contributor", + "branchContributorUserId": "U-", + "branchId": "B-", + "branchName": "main", + "branchShortHand": "@branch-contributor/main", + "projectBranchShortHand": "@example-user/example-project/@contributor/main" + }, + "title": "Add new feature" + }, + "project": { + "projectId": "P-", + "projectOwnerHandle": "project-owner", + "projectOwnerUserId": "U-", + "projectShortHand": "@example-user/example-project", + "projectSlug": "example-project" + }, + "status_update": { + "newStatus": "in_review", + "oldStatus": "draft" + } + } + }, + "eventId": "EVENT-", + "occurredAt": "", + "signed": "", + "topic": "project:contribution:updated" + }, + { + "data": { + "kind": "project:contribution:comment", + "link": "http://:1234/@example-user/example-project/contributions/1", + "payload": { + "comment": { + "author": { + "avatarUrl": null, + "handle": "example-user", + "name": "User Name", + "userId": "U-" + }, + "commentId": "CMT-", + "content": "This is a comment on the contribution.", + "createdAt": "" + }, + "contribution": { + "author": { + "avatarUrl": null, + "handle": "branch-contributor", + "name": "Branch Contributor", + "userId": "U-" + }, + "contributionId": "C-", + "description": "This contribution adds a new feature.", + "number": 1, + "sourceBranch": { + "branchContributorHandle": "branch-contributor", + "branchContributorUserId": "U-", + "branchId": "B-", + "branchName": "contribution-branch", + "branchShortHand": "@branch-contributor/main", + "projectBranchShortHand": "@example-user/example-project/@branch-contributor/contribution-branch" + }, + "status": "in_review", + "targetBranch": { + "branchContributorHandle": "branch-contributor", + "branchContributorUserId": "U-", + "branchId": "B-", + "branchName": "main", + "branchShortHand": "@branch-contributor/main", + "projectBranchShortHand": "@example-user/example-project/@contributor/main" + }, + "title": "Add new feature" + }, + "project": { + "projectId": "P-", + "projectOwnerHandle": "project-owner", + "projectOwnerUserId": "U-", + "projectShortHand": "@example-user/example-project", + "projectSlug": "example-project" + } + } + }, + "eventId": "EVENT-", + "occurredAt": "", + "signed": "", + "topic": "project:contribution:comment" + }, + { + "data": { + "kind": "project:ticket:created", + "link": "http://:1234/@example-user/example-project/tickets/1", + "payload": { + "project": { + "projectId": "P-", + "projectOwnerHandle": "project-owner", + "projectOwnerUserId": "U-", + "projectShortHand": "@example-user/example-project", + "projectSlug": "example-project" + }, + "ticket": { + "author": { + "avatarUrl": null, + "handle": "example-user", + "name": "User Name", + "userId": "U-" + }, + "createdAt": "", + "description": "There is a bug in the system.", + "number": 1, + "status": "open", + "ticketId": "T-", + "title": "Bug report" + } + } + }, + "eventId": "EVENT-", + "occurredAt": "", + "signed": "", + "topic": "project:ticket:created" + }, + { + "data": { + "kind": "project:ticket:updated", + "link": "http://:1234/@example-user/example-project/tickets/1", + "payload": { + "project": { + "projectId": "P-", + "projectOwnerHandle": "project-owner", + "projectOwnerUserId": "U-", + "projectShortHand": "@example-user/example-project", + "projectSlug": "example-project" + }, + "status_update": { + "newStatus": "closed", + "oldStatus": "open" + }, + "ticket": { + "author": { + "avatarUrl": null, + "handle": "example-user", + "name": "User Name", + "userId": "U-" + }, + "createdAt": "", + "description": "There is a bug in the system.", + "number": 1, + "status": "open", + "ticketId": "T-", + "title": "Bug report" + } + } + }, + "eventId": "EVENT-", + "occurredAt": "", + "signed": "", + "topic": "project:ticket:updated" + }, + { + "data": { + "kind": "project:ticket:comment", + "link": "http://:1234/@example-user/example-project/tickets/1", + "payload": { + "comment": { + "author": { + "avatarUrl": null, + "handle": "example-user", + "name": "User Name", + "userId": "U-" + }, + "commentId": "CMT-", + "content": "This is a comment on the contribution.", + "createdAt": "" + }, + "project": { + "projectId": "P-", + "projectOwnerHandle": "project-owner", + "projectOwnerUserId": "U-", + "projectShortHand": "@example-user/example-project", + "projectSlug": "example-project" + }, + "ticket": { + "author": { + "avatarUrl": null, + "handle": "example-user", + "name": "User Name", + "userId": "U-" + }, + "createdAt": "", + "description": "There is a bug in the system.", + "number": 1, + "status": "open", + "ticketId": "T-", + "title": "Bug report" + } + } + }, + "eventId": "EVENT-", + "occurredAt": "", + "signed": "", + "topic": "project:ticket:comment" + }, + { + "data": { + "kind": "project:release:created", + "link": "http://:1234/@example-user/example-project/releases/1.2.3", + "payload": { + "project": { + "projectId": "P-", + "projectOwnerHandle": "project-owner", + "projectOwnerUserId": "U-", + "projectShortHand": "@example-user/example-project", + "projectSlug": "example-project" + }, + "release": { + "createdAt": "", + "releaseId": "R-", + "version": "1.2.3" + } + } + }, + "eventId": "EVENT-", + "occurredAt": "", + "signed": "", + "topic": "project:release:created" + } + ], + "status": [ + { + "status_code": 200 + } + ] +}