From 35898c989f31dc86b036014cd99a83a2f126a04d Mon Sep 17 00:00:00 2001 From: Kyle Mistele Date: Thu, 15 Oct 2026 17:35:09 -0700 Subject: [PATCH] refactor: make provider imports go one way, so no file imports a file that imports it back Each provider's API service (GitHubApi, LinearApi, SlackApi) returned objects whose methods call the API service back: postIssueComment returned a GitHubIssueComment, whose addReaction() calls GitHubApi. So the API service files and the objects files imported each other. In Linear and Slack that crashed at startup depending on which file loaded first, and was patched over with second tags sharing the services' keys (LinearApiHandle, SlackApiHandle). Now each provider's files fall into three groups, and each imports only from the ones before it: - Plain data: refs, ids, and the plain records the API returns. No methods. - The API service: takes and returns plain data only, and never imports the objects. - Callback objects (GitHubIssue, GitHubIssueComment, LinearFile, SlackMessage, ...): wrap plain data; their methods call the API service and wrap what it returns. - The API services now return records (`GitHubIssueCommentRecord`, `LinearCommentRecord`, `SlackMessageRecord`, ...) instead of objects with methods. Callback code is unchanged: `issue.postComment()`, `thread.listMessages()`, and `file.download()` still return objects. - Objects with exactly a record's fields are built with `X.make(record)`; objects that hold other objects have a static `X.fromRecord(record)` (`LinearComment`, `SlackMessage`). - LinearApiHandle and SlackApiHandle are deleted; the objects use the real tags. - Slack's SlackModels.ts held both plain data and objects; SlackMessage and SlackFile move to their own files. This also fixes a startup crash that was already on main (importing SlackModels first). - Linear's plain file and resource data move to LinearFileModels.ts and LinearResourceRecords.ts; GitHub's plain data the API returns moves to GitHubModels.ts. - The Alchemy Cloudflare example's helpers that read comments from GitHubApi now take records. No package's source has an import cycle, and every provider module loads on its own. Co-Authored-By: Claude Opus 5.5 HumanLayer-Session: https://app.dev.codelayer.gg/sessions/01a1418a-45ea-7a36-87bf-d2e8c4e465fe --- .../src/DiscussionContext.ts | 8 +- .../alchemy-cloudflare/src/GitHubTools.ts | 8 +- examples/alchemy-cloudflare/src/GithubBot.ts | 8 +- .../test/check-tools.vi.test.ts | 8 +- .../test/discussion-context.vi.test.ts | 10 +- .../test/github-tools.vi.test.ts | 8 +- .../test/mention-access.vi.test.ts | 11 +- packages/github/README.md | 11 + packages/github/src/GitHubApiContract.ts | 52 +-- packages/github/src/GitHubEventProcessor.ts | 4 +- packages/github/src/GitHubModels.ts | 63 ++++ packages/github/src/GitHubResources.ts | 106 +++--- .../github/src/api/GitHubApiProjections.ts | 13 +- .../test/github-api-capabilities.vi.test.ts | 5 +- .../test/github-delivery-output.vi.test.ts | 6 +- .../test/github-remote-delivery.vi.test.ts | 6 +- .../github/test/github-resources.vi.test.ts | 30 +- packages/linear/README.md | 2 + packages/linear/src/LinearApi.ts | 306 +++++++++--------- packages/linear/src/LinearApiContract.ts | 45 +-- packages/linear/src/LinearApiHandle.ts | 13 - packages/linear/src/LinearApiLive.ts | 2 +- packages/linear/src/LinearEventProcessor.ts | 3 +- packages/linear/src/LinearFileModels.ts | 239 ++++++++++++++ packages/linear/src/LinearFiles.ts | 258 ++------------- packages/linear/src/LinearModels.ts | 3 + packages/linear/src/LinearResourceRecords.ts | 36 +++ packages/linear/src/LinearResources.ts | 113 +++---- packages/linear/src/api/CreateReaction.ts | 4 +- packages/linear/src/api/DownloadLinearFile.ts | 2 +- .../linear/src/api/LinearApiProjections.ts | 12 +- packages/linear/src/api/LinearHttpClient.ts | 2 +- .../linear/src/api/ListIssueAttachments.ts | 10 +- packages/linear/src/api/ListIssueComments.ts | 6 +- .../linear/src/api/RequestLinearFileUpload.ts | 2 +- packages/linear/src/index.ts | 2 + .../test/linear-delivery-output.vi.test.ts | 8 +- .../test/linear-file-download.vi.test.ts | 3 +- .../linear/test/linear-file-upload.vi.test.ts | 8 +- packages/linear/test/linear-files.vi.test.ts | 10 +- .../test/linear-remote-delivery.vi.test.ts | 4 +- .../test/linear-resource-methods.vi.test.ts | 46 +-- packages/slack/README.md | 2 +- .../slack/scripts/slack-api-live-smoke.ts | 4 +- packages/slack/src/SlackApi.ts | 22 +- packages/slack/src/SlackApiContract.ts | 29 +- packages/slack/src/SlackApiHandle.ts | 13 - packages/slack/src/SlackApiWire.ts | 4 +- packages/slack/src/SlackCallbackEvents.ts | 3 +- packages/slack/src/SlackChannel.ts | 13 +- packages/slack/src/SlackEventProcessor.ts | 8 +- packages/slack/src/SlackFile.ts | 56 ++++ packages/slack/src/SlackMessage.ts | 48 +++ packages/slack/src/SlackModels.ts | 108 ++----- packages/slack/src/SlackThread.ts | 12 +- packages/slack/src/index.ts | 2 + .../test/slack-api-file-download.vi.test.ts | 2 +- .../test/slack-api-file-upload.vi.test.ts | 6 +- .../test/slack-api-pagination.vi.test.ts | 23 +- .../test/slack-event-processing.vi.test.ts | 11 +- packages/slack/test/slack-file.vi.test.ts | 18 +- .../test/slack-thread-context.vi.test.ts | 21 +- 62 files changed, 1054 insertions(+), 847 deletions(-) delete mode 100644 packages/linear/src/LinearApiHandle.ts create mode 100644 packages/linear/src/LinearFileModels.ts create mode 100644 packages/linear/src/LinearResourceRecords.ts delete mode 100644 packages/slack/src/SlackApiHandle.ts create mode 100644 packages/slack/src/SlackFile.ts create mode 100644 packages/slack/src/SlackMessage.ts diff --git a/examples/alchemy-cloudflare/src/DiscussionContext.ts b/examples/alchemy-cloudflare/src/DiscussionContext.ts index 946840a..07e9f5a 100644 --- a/examples/alchemy-cloudflare/src/DiscussionContext.ts +++ b/examples/alchemy-cloudflare/src/DiscussionContext.ts @@ -8,11 +8,11 @@ import { type GitHubApi, type GitHubApiError, type GitHubIssue, - type GitHubIssueComment, + type GitHubIssueCommentRecord, type GitHubParticipant, type GitHubPullRequest, type GitHubReview, - type GitHubReviewComment, + type GitHubReviewCommentRecord, } from '@humanlayer/channels-github' import { Array as Arr, Effect, Match, Option, Predicate, Schema } from 'effect' @@ -55,7 +55,7 @@ const clip = (body: string) => ? body : `${body.slice(0, MAX_BODY_LENGTH)}\n[Cut off at ${MAX_BODY_LENGTH} of ${body.length} characters.]` -const commentEntry = (comment: GitHubIssueComment): Entry => ({ +const commentEntry = (comment: GitHubIssueCommentRecord): Entry => ({ id: comment.ref.id, text: `${author(comment.author)} commented (comment ${comment.ref.id}):\n${clip(comment.body)}`, }) @@ -65,7 +65,7 @@ const reviewEntry = (review: GitHubReview): Entry => ({ text: `${author(review.author)} reviewed (${review.state.replace('_', ' ')})${review.body ? `:\n${clip(review.body)}` : '.'}`, }) -const reviewCommentEntry = (comment: GitHubReviewComment): Entry => { +const reviewCommentEntry = (comment: GitHubReviewCommentRecord): Entry => { const line = comment.line ?? comment.startLine const where = line === null || line === undefined ? comment.path : `${comment.path}:${line}` const thread = comment.inReplyToId ?? comment.ref.id diff --git a/examples/alchemy-cloudflare/src/GitHubTools.ts b/examples/alchemy-cloudflare/src/GitHubTools.ts index ebcde52..d7958ab 100644 --- a/examples/alchemy-cloudflare/src/GitHubTools.ts +++ b/examples/alchemy-cloudflare/src/GitHubTools.ts @@ -7,9 +7,9 @@ import { GitHubApi, GitHubRepositoryRef, type GitHubIssue, - type GitHubIssueComment, + type GitHubIssueCommentRecord, type GitHubPullRequest, - type GitHubReviewComment, + type GitHubReviewCommentRecord, } from '@humanlayer/channels-github' import { defineTool, ToolResultFailure, ToolResultText, type FoldTool } from '@humanlayer/fold-core' import { Data, Effect, Match, Predicate, Schema } from 'effect' @@ -36,7 +36,7 @@ const POST_COMMENT_DESCRIPTION = 'Post a GitHub Markdown comment. Your final answer is posted for you when you finish, so use this only for something else, such as a reply in a review thread or an update while you work.' /** A comment as the agent sees it: what it needs to read it, link to it, and answer it. */ -const commentSummary = (comment: GitHubIssueComment) => ({ +const commentSummary = (comment: GitHubIssueCommentRecord) => ({ id: comment.ref.id, author: comment.author?.login ?? null, url: comment.url, @@ -44,7 +44,7 @@ const commentSummary = (comment: GitHubIssueComment) => ({ }) /** A line comment as the agent sees it, with the thread to reply in: GitHub threads replies under the first comment. */ -const reviewCommentSummary = (comment: GitHubReviewComment) => ({ +const reviewCommentSummary = (comment: GitHubReviewCommentRecord) => ({ id: comment.ref.id, thread: comment.inReplyToId ?? comment.ref.id, author: comment.author?.login ?? null, diff --git a/examples/alchemy-cloudflare/src/GithubBot.ts b/examples/alchemy-cloudflare/src/GithubBot.ts index b7dbde5..0ddc439 100644 --- a/examples/alchemy-cloudflare/src/GithubBot.ts +++ b/examples/alchemy-cloudflare/src/GithubBot.ts @@ -22,7 +22,7 @@ import { type GitHubApiError, type GitHubCallbackHandlers, type GitHubCheckConclusion, - type GitHubIssueComment, + type GitHubIssueCommentRecord, type GitHubIssueCreated, type GitHubMentioned, type GitHubParticipant, @@ -30,7 +30,7 @@ import { type GitHubPrCreated, type GitHubPrEvent, type GitHubRepositoryRef, - type GitHubReviewComment, + type GitHubReviewCommentRecord, } from '@humanlayer/channels-github' import type { RuntimeContext } from 'alchemy/RuntimeContext' import { Array as Arr, Config, Effect, Match, Predicate, Schema } from 'effect' @@ -114,14 +114,14 @@ interface RequestItem { const authorOf = (author: GitHubParticipant | null) => (author === null ? 'a deleted user' : `@${author.login}`) -const commentItem = (comment: GitHubIssueComment): RequestItem => ({ +const commentItem = (comment: GitHubIssueCommentRecord): RequestItem => ({ comment: RequestComment.cases.Comment.make({ id: comment.ref.id }), body: comment.body, text: `${authorOf(comment.author)} commented (comment ${comment.ref.id}):\n${comment.body}`, }) /** A line comment with where it is in the diff and the thread to reply in: GitHub threads replies under the first. */ -const lineCommentItem = (comment: GitHubReviewComment): RequestItem => { +const lineCommentItem = (comment: GitHubReviewCommentRecord): RequestItem => { const line = comment.line ?? comment.startLine const where = Predicate.isNullish(line) ? comment.path : `${comment.path}:${line}` const thread = comment.inReplyToId ?? comment.ref.id diff --git a/examples/alchemy-cloudflare/test/check-tools.vi.test.ts b/examples/alchemy-cloudflare/test/check-tools.vi.test.ts index 0161908..a6f74f2 100644 --- a/examples/alchemy-cloudflare/test/check-tools.vi.test.ts +++ b/examples/alchemy-cloudflare/test/check-tools.vi.test.ts @@ -1,8 +1,8 @@ import { describe, it } from '@effect/vitest' import { - GitHubActionsJob, + GitHubActionsJobRecord, GitHubApi, - GitHubCheckRun, + GitHubCheckRunRecord, GitHubCheckRunInfo, GitHubEventId, GitHubId, @@ -86,7 +86,7 @@ const apiWithLog = (log: string) => baseSha: 'def', }), ), - listCheckRunsForRef: () => Effect.succeed(infos.map((info) => GitHubCheckRun.make({ ref: info.ref }))), + listCheckRunsForRef: () => Effect.succeed(infos.map((info) => GitHubCheckRunRecord.make({ ref: info.ref }))), fetchCheckRun: ({ checkRun }) => Effect.succeed(infos.find((info) => info.ref.id === checkRun.id) ?? infos[0]!), listCheckRunAnnotations: () => Effect.succeed([ @@ -104,7 +104,7 @@ const apiWithLog = (log: string) => }, ]), resolveActionsJob: () => - Effect.succeed(GitHubActionsJob.make({ ref: { ...repository, id: GitHubId.make(900) } })), + Effect.succeed(GitHubActionsJobRecord.make({ ref: { ...repository, id: GitHubId.make(900) } })), fetchActionsJob: () => Effect.succeed({ ref: { ...repository, id: GitHubId.make(900) }, diff --git a/examples/alchemy-cloudflare/test/discussion-context.vi.test.ts b/examples/alchemy-cloudflare/test/discussion-context.vi.test.ts index cbeb8b3..10b6e72 100644 --- a/examples/alchemy-cloudflare/test/discussion-context.vi.test.ts +++ b/examples/alchemy-cloudflare/test/discussion-context.vi.test.ts @@ -4,14 +4,14 @@ import { GitHubDiscussionRef, GitHubId, GitHubIssue, - GitHubIssueComment, + GitHubIssueCommentRecord, GitHubIssueInfo, GitHubIssueRef, GitHubParticipant, GitHubPullRequest, GitHubPullRequestInfo, GitHubPullRequestRef, - GitHubReviewComment, + GitHubReviewCommentRecord, } from '@humanlayer/channels-github' import { Effect, Layer, Option } from 'effect' @@ -37,7 +37,7 @@ const bob = GitHubParticipant.make({ id: GitHubId.make(2), login: 'bob', type: ' const bot = GitHubParticipant.make({ id: GitHubId.make(BOT_USER_ID), login: 'agent[bot]', type: 'Bot' }) const comment = (discussion: GitHubDiscussionRef, id: number, author: GitHubParticipant, body: string) => - GitHubIssueComment.make({ + GitHubIssueCommentRecord.make({ ref: { discussion, id: GitHubId.make(id) }, body, url: `https://github.com/humanlayer/effect-channels/issues/42#issuecomment-${id}`, @@ -96,7 +96,7 @@ const api = Layer.mock(GitHubApi, { ]), listPullRequestReviewComments: () => Effect.succeed([ - GitHubReviewComment.make({ + GitHubReviewCommentRecord.make({ ref: { pullRequest: pullRequest.ref, id: GitHubId.make(40) }, nodeId: 'review-comment-40', body: 'This can be null.', @@ -109,7 +109,7 @@ const api = Layer.mock(GitHubApi, { diffHunk: '@@ -1 +1 @@', line: 12, }), - GitHubReviewComment.make({ + GitHubReviewCommentRecord.make({ ref: { pullRequest: pullRequest.ref, id: GitHubId.make(41) }, nodeId: 'review-comment-41', body: '@agent fix this', diff --git a/examples/alchemy-cloudflare/test/github-tools.vi.test.ts b/examples/alchemy-cloudflare/test/github-tools.vi.test.ts index 2087cfd..6f94af9 100644 --- a/examples/alchemy-cloudflare/test/github-tools.vi.test.ts +++ b/examples/alchemy-cloudflare/test/github-tools.vi.test.ts @@ -3,10 +3,10 @@ import { GitHubApi, GitHubDiscussionRef, GitHubId, - GitHubIssueComment, + GitHubIssueCommentRecord, GitHubPullRequest, GitHubPullRequestRef, - GitHubReviewComment, + GitHubReviewCommentRecord, type GitHubReplyToReviewComment, } from '@humanlayer/channels-github' import { ToolResultText } from '@humanlayer/fold-core' @@ -26,7 +26,7 @@ const pullRequest = GitHubPullRequest.make({ }) const lineComment = (id: number, inReplyToId?: number) => - GitHubReviewComment.make({ + GitHubReviewCommentRecord.make({ ref: { pullRequest: pullRequest.ref, id: GitHubId.make(id) }, nodeId: `review-comment-${id}`, body: 'This can be null.', @@ -54,7 +54,7 @@ const post = (request: { readonly markdown: string; readonly replyTo?: number }) postPullRequestComment: ({ content }) => Ref.update(posts, (all) => [...all, content.markdown]).pipe( Effect.as( - GitHubIssueComment.make({ + GitHubIssueCommentRecord.make({ ref: { discussion: GitHubDiscussionRef.cases.PullRequest.make({ ref: pullRequest.ref, diff --git a/examples/alchemy-cloudflare/test/mention-access.vi.test.ts b/examples/alchemy-cloudflare/test/mention-access.vi.test.ts index b69f7e2..117c8e4 100644 --- a/examples/alchemy-cloudflare/test/mention-access.vi.test.ts +++ b/examples/alchemy-cloudflare/test/mention-access.vi.test.ts @@ -11,6 +11,7 @@ import { GitHubIssue, GitHubIssueComment, GitHubIssueCommentCreated, + GitHubIssueCommentRecord, GitHubIssueMentioned, GitHubIssueOpened, GitHubIssueRef, @@ -59,7 +60,7 @@ const discussionComment = ( body = maintainerOnlyNotice, id = 500, ) => - GitHubIssueComment.make({ + GitHubIssueCommentRecord.make({ ref: { discussion, id: GitHubId.make(id) }, body, url: 'https://github.com/humanlayer/effect-channels/issues/42#issuecomment-500', @@ -74,13 +75,13 @@ const fixtures = (actor = person(1)) => { issue, actor, eventId, - comment: discussionComment(issueDiscussion, actor, '@agent help', actor.id), + comment: GitHubIssueComment.make(discussionComment(issueDiscussion, actor, '@agent help', actor.id)), }) const prComment = GitHubPrCommentCreated.make({ pullRequest, actor, eventId, - comment: discussionComment(prDiscussion, actor, '@agent help', actor.id), + comment: GitHubIssueComment.make(discussionComment(prDiscussion, actor, '@agent help', actor.id)), }) const reviewComment = GitHubPrReviewCommentCreated.make({ pullRequest, @@ -150,13 +151,13 @@ interface ApiState { readonly reactions: ReadonlyArray readonly lists: ReadonlyArray readonly posts: ReadonlyArray<{ readonly discussion: GitHubDiscussionRef; readonly content: GitHubContent }> - readonly comments: ReadonlyArray + readonly comments: ReadonlyArray } const makeApi = ( access: Effect.Effect, options: { - readonly comments?: ReadonlyArray + readonly comments?: ReadonlyArray readonly lookupFailure?: GitHubApiError readonly ambiguousPostFailure?: GitHubApiError } = {}, diff --git a/packages/github/README.md b/packages/github/README.md index e85d15f..d06bc27 100644 --- a/packages/github/README.md +++ b/packages/github/README.md @@ -130,6 +130,17 @@ Add `github` to the providers passed to `Channels.make` or the host-specific wra Callbacks provide `GitHubIssue` and `GitHubPullRequest` resources. Their methods require `GitHubApi`, which the application provides, usually with `GitHubApiLive`; the bot builds no GitHub client of its own. List methods follow GitHub pagination automatically with 100 items per request. +`GitHubApi` itself returns plain records with no methods: `GitHubIssueCommentRecord`, `GitHubReviewCommentRecord`, `GitHubCheckRunRecord`, `GitHubActionsJobRecord`, and lists of them. Resource methods wrap these in the `GitHubIssueComment`, `GitHubReviewComment`, `GitHubCheckRun`, and `GitHubActionsJob` objects, so `issue.postComment(...)` returns a comment with `addReaction()`. Code that calls `GitHubApi` directly and needs those methods wraps the record, as in `GitHubIssueComment.make(record)`, or `commentFromRecord(record)` for an `updateComment` result. + +```ts +const comment = yield * issue.postComment(content) // GitHubIssueComment +yield * comment.addReaction('eyes') + +const api = yield * GitHubApi +const record = yield * api.postIssueComment({ issue: issue.ref, content }) // GitHubIssueCommentRecord +yield * GitHubIssueComment.make(record).addReaction('eyes') +``` + ### Issues In addition to `fetchInfo()`, comment operations, and subscription operations, an issue supports: diff --git a/packages/github/src/GitHubApiContract.ts b/packages/github/src/GitHubApiContract.ts index 54455de..67747ea 100644 --- a/packages/github/src/GitHubApiContract.ts +++ b/packages/github/src/GitHubApiContract.ts @@ -5,15 +5,24 @@ import { type GitHubAccessLevel, GitHubActionsJobRef, type GitHubActionsJobInfo, + type GitHubActionsJobRecord, + type GitHubCheckAnnotations, GitHubCheckRunRef, type GitHubCheckRunInfo, + type GitHubCheckRunRecords, GitHubCommentRef, + type GitHubCommentRecord, + type GitHubCommits, GitHubContent, GitHubIssueCommentRef, + type GitHubIssueCommentRecord, + type GitHubIssueCommentRecords, type GitHubIssueInfo, + type GitHubLabelsResult, GitHubIssueRef, GitHubMergeMethod, type GitHubMergeResult, + type GitHubPullRequestFiles, type GitHubPullRequestInfo, GitHubPullRequestRef, GitHubReaction, @@ -21,21 +30,10 @@ import { GitHubReviewCommentLocation, GitHubRepositoryRef, GitHubReviewCommentRef, + type GitHubReviewCommentRecord, + type GitHubReviewCommentRecords, + type GitHubReviews, } from './GitHubModels' -import type { - GitHubActionsJob, - GitHubCheckAnnotations, - GitHubCheckRuns, - GitHubComment, - GitHubCommits, - GitHubIssueComment, - GitHubIssueComments, - GitHubLabelsResult, - GitHubPullRequestFiles, - GitHubReviewComment, - GitHubReviewComments, - GitHubReviews, -} from './GitHubResources' export const GitHubIssueRequest = Schema.Struct({ issue: GitHubIssueRef }) export interface GitHubIssueRequest extends Schema.Schema.Type {} @@ -269,14 +267,14 @@ export class GitHubApiError extends Schema.TaggedError()('GitHub export type GitHubApiOperations = { readonly fetchIssue: (input: GitHubIssueRequest) => Effect.Effect readonly fetchPullRequest: (input: GitHubPullRequestRequest) => Effect.Effect - readonly listIssueComments: (input: GitHubIssueRequest) => Effect.Effect + readonly listIssueComments: (input: GitHubIssueRequest) => Effect.Effect readonly listPullRequestComments: ( input: GitHubPullRequestRequest, - ) => Effect.Effect + ) => Effect.Effect readonly listPullRequestReviews: (input: GitHubPullRequestRequest) => Effect.Effect readonly listPullRequestReviewComments: ( input: GitHubPullRequestRequest, - ) => Effect.Effect + ) => Effect.Effect readonly listPullRequestFiles: ( input: GitHubPullRequestRequest, ) => Effect.Effect @@ -301,17 +299,19 @@ export type GitHubApiOperations = { ) => Effect.Effect readonly removeAllIssueLabels: (input: GitHubIssueRequest) => Effect.Effect readonly removeAllPullRequestLabels: (input: GitHubPullRequestRequest) => Effect.Effect - readonly postIssueComment: (input: GitHubPostIssueComment) => Effect.Effect + readonly postIssueComment: ( + input: GitHubPostIssueComment, + ) => Effect.Effect readonly postPullRequestComment: ( input: GitHubPostPullRequestComment, - ) => Effect.Effect + ) => Effect.Effect readonly postPullRequestReviewComment: ( input: GitHubPostPullRequestReviewComment, - ) => Effect.Effect + ) => Effect.Effect readonly replyToReviewComment: ( input: GitHubReplyToReviewComment, - ) => Effect.Effect - readonly updateComment: (input: GitHubUpdateComment) => Effect.Effect + ) => Effect.Effect + readonly updateComment: (input: GitHubUpdateComment) => Effect.Effect readonly deleteComment: (input: GitHubDeleteComment) => Effect.Effect /** Add the bot's reaction to a comment, issue, or pull request. Already present counts as added. */ readonly addReaction: (input: GitHubReactionRequest) => Effect.Effect @@ -329,12 +329,16 @@ export type GitHubApiOperations = { input: GitHubListPullRequestsForBranch, ) => Effect.Effect, GitHubApiError> readonly createPullRequest: (input: GitHubCreatePullRequest) => Effect.Effect - readonly listCheckRunsForRef: (input: GitHubListCheckRunsForRef) => Effect.Effect + readonly listCheckRunsForRef: ( + input: GitHubListCheckRunsForRef, + ) => Effect.Effect readonly fetchCheckRun: (input: GitHubCheckRunRequest) => Effect.Effect readonly listCheckRunAnnotations: ( input: GitHubCheckRunRequest, ) => Effect.Effect - readonly resolveActionsJob: (input: GitHubCheckRunRequest) => Effect.Effect + readonly resolveActionsJob: ( + input: GitHubCheckRunRequest, + ) => Effect.Effect readonly fetchActionsJob: (input: GitHubActionsJobRequest) => Effect.Effect readonly downloadActionsJobLog: (input: GitHubActionsJobRequest) => Effect.Effect /** A user's access to a repository. An account that is not a GitHub user fails with `not_found`. */ diff --git a/packages/github/src/GitHubEventProcessor.ts b/packages/github/src/GitHubEventProcessor.ts index d0c9963..64eaaf9 100644 --- a/packages/github/src/GitHubEventProcessor.ts +++ b/packages/github/src/GitHubEventProcessor.ts @@ -98,7 +98,7 @@ import { GitHubReviewThread, GitHubTeam, } from './GitHubModels' -import { GitHubIssue, GitHubIssueComment, GitHubPullRequest } from './GitHubResources' +import { GitHubIssue, GitHubIssueComment, GitHubPullRequest, GitHubReviewComment } from './GitHubResources' import type { GitHubIssueCommentWebhook, GitHubIssuesWebhook, @@ -505,7 +505,7 @@ const normalizeWebhook = ( const ref = pullRequestRef(payload) const pullRequest = GitHubPullRequest.make({ ref, mailboxKey }) const actor = participant(payload.sender) - const comment = reviewComment(ref, payload.comment) + const comment = GitHubReviewComment.make(reviewComment(ref, payload.comment)) const event = Match.value(payload.action).pipe( Match.when('created', () => GitHubPrReviewCommentCreated.make({ eventId, pullRequest, actor, comment }), diff --git a/packages/github/src/GitHubModels.ts b/packages/github/src/GitHubModels.ts index 79c4ffd..82b73da 100644 --- a/packages/github/src/GitHubModels.ts +++ b/packages/github/src/GitHubModels.ts @@ -326,3 +326,66 @@ export const hasGitHubAccess = (input: { readonly access: GitHubAccessLevel readonly minimum: GitHubAccessLevel }): boolean => Order.isGreaterThanOrEqualTo(GitHubAccessLevelOrder)(input.access, input.minimum) + +/** A comment on an issue or pull request conversation, as `GitHubApi` returns it. `GitHubIssueComment` adds its methods. */ +export const GitHubIssueCommentRecord = Schema.Struct({ + ref: GitHubIssueCommentRef, + body: Schema.String, + url: Schema.String, + author: Schema.NullOr(GitHubParticipant), +}) +export interface GitHubIssueCommentRecord extends Schema.Schema.Type {} + +/** A comment on a pull request diff, as `GitHubApi` returns it. `GitHubReviewComment` adds its methods. */ +export const GitHubReviewCommentRecord = Schema.Struct({ + ref: GitHubReviewCommentRef, + nodeId: Schema.NonEmptyString, + body: Schema.String, + url: Schema.String, + author: Schema.NullOr(GitHubParticipant), + reviewId: Schema.NullOr(GitHubId), + path: Schema.String, + commitId: Schema.String, + originalCommitId: Schema.String, + diffHunk: Schema.String, + inReplyToId: Schema.optionalKey(Schema.NullOr(GitHubId)), + line: Schema.optionalKey(Schema.NullOr(GitHubDiffLine)), + startLine: Schema.optionalKey(Schema.NullOr(GitHubDiffLine)), + side: Schema.optionalKey(GitHubDiffSide), +}) +export interface GitHubReviewCommentRecord extends Schema.Schema.Type {} + +export const GitHubCommentRecord = Schema.Union([GitHubIssueCommentRecord, GitHubReviewCommentRecord]) +export type GitHubCommentRecord = typeof GitHubCommentRecord.Type + +/** A check run, as `GitHubApi` returns it. `GitHubCheckRun` adds its methods. */ +export const GitHubCheckRunRecord = Schema.Struct({ ref: GitHubCheckRunRef }) +export interface GitHubCheckRunRecord extends Schema.Schema.Type {} + +/** A GitHub Actions job, as `GitHubApi` returns it. `GitHubActionsJob` adds its methods. */ +export const GitHubActionsJobRecord = Schema.Struct({ ref: GitHubActionsJobRef }) +export interface GitHubActionsJobRecord extends Schema.Schema.Type {} + +export const GitHubIssueCommentRecords = Schema.Array(GitHubIssueCommentRecord) +export type GitHubIssueCommentRecords = typeof GitHubIssueCommentRecords.Type + +export const GitHubReviewCommentRecords = Schema.Array(GitHubReviewCommentRecord) +export type GitHubReviewCommentRecords = typeof GitHubReviewCommentRecords.Type + +export const GitHubCheckRunRecords = Schema.Array(GitHubCheckRunRecord) +export type GitHubCheckRunRecords = typeof GitHubCheckRunRecords.Type + +export const GitHubReviews = Schema.Array(GitHubReview) +export type GitHubReviews = typeof GitHubReviews.Type + +export const GitHubLabelsResult = Schema.Array(GitHubLabel) +export type GitHubLabelsResult = typeof GitHubLabelsResult.Type + +export const GitHubPullRequestFiles = Schema.Array(GitHubPullRequestFile) +export type GitHubPullRequestFiles = typeof GitHubPullRequestFiles.Type + +export const GitHubCommits = Schema.Array(GitHubCommit) +export type GitHubCommits = typeof GitHubCommits.Type + +export const GitHubCheckAnnotations = Schema.Array(GitHubCheckAnnotation) +export type GitHubCheckAnnotations = typeof GitHubCheckAnnotations.Type diff --git a/packages/github/src/GitHubResources.ts b/packages/github/src/GitHubResources.ts index 72e9fc5..9208abc 100644 --- a/packages/github/src/GitHubResources.ts +++ b/packages/github/src/GitHubResources.ts @@ -13,30 +13,30 @@ import type { GitHubPostReviewCommentOptions, } from './GitHubApi' import { GitHubApi } from './GitHubApi' -import { GitHubId } from './GitHubIdentity' import { type GitHubAccessLevel, GitHubActionsJobRef, type GitHubActionsJobInfo, - GitHubCheckAnnotation, + GitHubActionsJobRecord, + type GitHubCheckAnnotations, GitHubCheckRunRef, type GitHubCheckRunInfo, - GitHubCommit, + GitHubCheckRunRecord, + type GitHubCommentRecord, + type GitHubCommits, type GitHubContent, - GitHubDiffLine, - GitHubIssueCommentRef, + GitHubIssueCommentRecord, type GitHubIssueInfo, GitHubIssueRef, - GitHubLabel, + type GitHubLabelsResult, type GitHubMergeResult, - GitHubPullRequestFile, + type GitHubPullRequestFiles, type GitHubPullRequestInfo, GitHubPullRequestRef, type GitHubReaction, GitHubReactionTarget, - GitHubReview, - GitHubReviewCommentRef, - GitHubParticipant, + GitHubReviewCommentRecord, + type GitHubReviews, } from './GitHubModels' const issueSpanAttributes = (ref: GitHubIssueRef) => ({ @@ -93,12 +93,14 @@ export class GitHubIssue extends Schema.TaggedClass()('GitHubIssue' listComments(): Effect.Effect { return Effect.flatMap(GitHubApi, (api) => api.listIssueComments({ issue: this.ref })).pipe( + Effect.map(issueComments), Effect.withSpan('github.issue.list_comments', { attributes: issueSpanAttributes(this.ref) }), ) } postComment(content: GitHubContent): Effect.Effect { return Effect.flatMap(GitHubApi, (api) => api.postIssueComment({ issue: this.ref, content })).pipe( + Effect.map((record) => GitHubIssueComment.make(record)), Effect.withSpan('github.issue.post_comment', { attributes: issueSpanAttributes(this.ref) }), ) } @@ -185,6 +187,7 @@ export class GitHubPullRequest extends Schema.TaggedClass()(' listComments(): Effect.Effect { return Effect.flatMap(GitHubApi, (api) => api.listPullRequestComments({ pullRequest: this.ref })).pipe( + Effect.map(issueComments), Effect.withSpan('github.pull_request.list_comments', { attributes: pullRequestSpanAttributes(this.ref) }), ) } @@ -197,6 +200,7 @@ export class GitHubPullRequest extends Schema.TaggedClass()(' listReviewComments(): Effect.Effect { return Effect.flatMap(GitHubApi, (api) => api.listPullRequestReviewComments({ pullRequest: this.ref })).pipe( + Effect.map(reviewComments), Effect.withSpan('github.pull_request.list_review_comments', { attributes: pullRequestSpanAttributes(this.ref), }), @@ -205,6 +209,7 @@ export class GitHubPullRequest extends Schema.TaggedClass()(' postComment(content: GitHubContent): Effect.Effect { return Effect.flatMap(GitHubApi, (api) => api.postPullRequestComment({ pullRequest: this.ref, content })).pipe( + Effect.map((record) => GitHubIssueComment.make(record)), Effect.withSpan('github.pull_request.post_comment', { attributes: pullRequestSpanAttributes(this.ref) }), ) } @@ -221,6 +226,7 @@ export class GitHubPullRequest extends Schema.TaggedClass()(' location: input.location, }), ).pipe( + Effect.map((record) => GitHubReviewComment.make(record)), Effect.withSpan('github.pull_request.post_review_comment', { attributes: pullRequestSpanAttributes(this.ref), }), @@ -282,7 +288,8 @@ export class GitHubPullRequest extends Schema.TaggedClass()(' return Effect.gen(function* () { const api = yield* GitHubApi const pullRequest = yield* api.fetchPullRequest({ pullRequest: ref }) - return yield* api.listCheckRunsForRef({ pullRequest: ref, sha: pullRequest.headSha }) + const records = yield* api.listCheckRunsForRef({ pullRequest: ref, sha: pullRequest.headSha }) + return checkRuns(records) }).pipe( Effect.withSpan('github.pull_request.list_check_runs', { attributes: pullRequestSpanAttributes(this.ref), @@ -292,6 +299,7 @@ export class GitHubPullRequest extends Schema.TaggedClass()(' listCheckRunsForRef(sha: string): Effect.Effect { return Effect.flatMap(GitHubApi, (api) => api.listCheckRunsForRef({ pullRequest: this.ref, sha })).pipe( + Effect.map(checkRuns), Effect.withSpan('github.pull_request.list_check_runs_for_ref', { attributes: pullRequestSpanAttributes(this.ref), }), @@ -326,9 +334,10 @@ export class GitHubPullRequest extends Schema.TaggedClass()(' } } -export class GitHubCheckRun extends Schema.TaggedClass()('GitHubCheckRun', { - ref: GitHubCheckRunRef, -}) { +export class GitHubCheckRun extends Schema.TaggedClass()( + 'GitHubCheckRun', + GitHubCheckRunRecord.fields, +) { fetchInfo(): Effect.Effect { return Effect.flatMap(GitHubApi, (api) => api.fetchCheckRun({ checkRun: this.ref })).pipe( Effect.withSpan('github.check_run.fetch_info', { attributes: checkRunSpanAttributes(this.ref) }), @@ -343,14 +352,16 @@ export class GitHubCheckRun extends Schema.TaggedClass()('GitHub resolveActionsJob(): Effect.Effect { return Effect.flatMap(GitHubApi, (api) => api.resolveActionsJob({ checkRun: this.ref })).pipe( + Effect.map((record) => (record === null ? null : GitHubActionsJob.make(record))), Effect.withSpan('github.check_run.resolve_actions_job', { attributes: checkRunSpanAttributes(this.ref) }), ) } } -export class GitHubActionsJob extends Schema.TaggedClass()('GitHubActionsJob', { - ref: GitHubActionsJobRef, -}) { +export class GitHubActionsJob extends Schema.TaggedClass()( + 'GitHubActionsJob', + GitHubActionsJobRecord.fields, +) { fetchInfo(): Effect.Effect { return Effect.flatMap(GitHubApi, (api) => api.fetchActionsJob({ job: this.ref })).pipe( Effect.withSpan('github.actions_job.fetch_info', { attributes: actionsJobSpanAttributes(this.ref) }), @@ -364,14 +375,13 @@ export class GitHubActionsJob extends Schema.TaggedClass()('Gi } } -export class GitHubIssueComment extends Schema.TaggedClass()('GitHubIssueComment', { - ref: GitHubIssueCommentRef, - body: Schema.String, - url: Schema.String, - author: Schema.NullOr(GitHubParticipant), -}) { +export class GitHubIssueComment extends Schema.TaggedClass()( + 'GitHubIssueComment', + GitHubIssueCommentRecord.fields, +) { update(content: GitHubContent): Effect.Effect { return Effect.flatMap(GitHubApi, (api) => api.updateComment({ comment: this.ref, content })).pipe( + Effect.map(commentFromRecord), Effect.withSpan('github.comment.update'), ) } @@ -395,30 +405,22 @@ export class GitHubIssueComment extends Schema.TaggedClass() } } -export class GitHubReviewComment extends Schema.TaggedClass()('GitHubReviewComment', { - ref: GitHubReviewCommentRef, - nodeId: Schema.NonEmptyString, - body: Schema.String, - url: Schema.String, - author: Schema.NullOr(GitHubParticipant), - reviewId: Schema.NullOr(GitHubId), - path: Schema.String, - commitId: Schema.String, - originalCommitId: Schema.String, - diffHunk: Schema.String, - inReplyToId: Schema.optionalKey(Schema.NullOr(GitHubId)), - line: Schema.optionalKey(Schema.NullOr(GitHubDiffLine)), - startLine: Schema.optionalKey(Schema.NullOr(GitHubDiffLine)), - side: Schema.optionalKey(Schema.Literals(['LEFT', 'RIGHT'])), -}) { +export class GitHubReviewComment extends Schema.TaggedClass()( + 'GitHubReviewComment', + GitHubReviewCommentRecord.fields, +) { reply(content: GitHubContent): Effect.Effect { return Effect.flatMap(GitHubApi, (api) => api.replyToReviewComment({ pullRequest: this.ref.pullRequest, comment: this.ref, content }), - ).pipe(Effect.withSpan('github.review_comment.reply')) + ).pipe( + Effect.map((record) => GitHubReviewComment.make(record)), + Effect.withSpan('github.review_comment.reply'), + ) } update(content: GitHubContent): Effect.Effect { return Effect.flatMap(GitHubApi, (api) => api.updateComment({ comment: this.ref, content })).pipe( + Effect.map(commentFromRecord), Effect.withSpan('github.review_comment.update'), ) } @@ -448,23 +450,21 @@ export type GitHubComment = typeof GitHubComment.Type export const GitHubIssueComments = Schema.Array(GitHubIssueComment) export type GitHubIssueComments = typeof GitHubIssueComments.Type -export const GitHubReviews = Schema.Array(GitHubReview) -export type GitHubReviews = typeof GitHubReviews.Type - export const GitHubReviewComments = Schema.Array(GitHubReviewComment) export type GitHubReviewComments = typeof GitHubReviewComments.Type -export const GitHubLabelsResult = Schema.Array(GitHubLabel) -export type GitHubLabelsResult = typeof GitHubLabelsResult.Type +export const GitHubCheckRuns = Schema.Array(GitHubCheckRun) +export type GitHubCheckRuns = typeof GitHubCheckRuns.Type -export const GitHubPullRequestFiles = Schema.Array(GitHubPullRequestFile) -export type GitHubPullRequestFiles = typeof GitHubPullRequestFiles.Type +const issueComments = (records: ReadonlyArray): GitHubIssueComments => + records.map((record) => GitHubIssueComment.make(record)) -export const GitHubCommits = Schema.Array(GitHubCommit) -export type GitHubCommits = typeof GitHubCommits.Type +const reviewComments = (records: ReadonlyArray): GitHubReviewComments => + records.map((record) => GitHubReviewComment.make(record)) -export const GitHubCheckRuns = Schema.Array(GitHubCheckRun) -export type GitHubCheckRuns = typeof GitHubCheckRuns.Type +const checkRuns = (records: ReadonlyArray): GitHubCheckRuns => + records.map((record) => GitHubCheckRun.make(record)) -export const GitHubCheckAnnotations = Schema.Array(GitHubCheckAnnotation) -export type GitHubCheckAnnotations = typeof GitHubCheckAnnotations.Type +/** The comment object for a comment `GitHubApi` returned. */ +const commentFromRecord = (record: GitHubCommentRecord): GitHubComment => + Schema.is(GitHubReviewCommentRecord)(record) ? GitHubReviewComment.make(record) : GitHubIssueComment.make(record) diff --git a/packages/github/src/api/GitHubApiProjections.ts b/packages/github/src/api/GitHubApiProjections.ts index aea2b43..452aeaa 100644 --- a/packages/github/src/api/GitHubApiProjections.ts +++ b/packages/github/src/api/GitHubApiProjections.ts @@ -4,14 +4,17 @@ import type { GitHubPostPullRequestReviewComment } from '../GitHubApiContract' import { GitHubAccessLevel, GitHubActionsJobInfo, + GitHubActionsJobRecord, type GitHubActionsJobRef, GitHubCheckAnnotation, GitHubCheckRunInfo, + GitHubCheckRunRecord, type GitHubCheckRunRef, type GitHubCommentRef, type GitHubDiscussionRef, GitHubCommit, type GitHubIssueCommentRef, + GitHubIssueCommentRecord, GitHubIssueInfo, type GitHubIssueRef, GitHubLabel, @@ -23,9 +26,9 @@ import { type GitHubRepositoryRef, GitHubReview, GitHubReviewCommentRef, + GitHubReviewCommentRecord, type GitHubReviewState, } from '../GitHubModels' -import { GitHubActionsJob, GitHubCheckRun, GitHubIssueComment, GitHubReviewComment } from '../GitHubResources' import * as Api from './GitHubApiSchemas' export const repositoryPath = (ref: GitHubRepositoryRef) => @@ -73,7 +76,7 @@ export const pullRequestInfo = (ref: GitHubPullRequestRef, value: typeof Api.Pul baseSha: value.base.sha, }) export const issueComment = (discussion: GitHubIssueCommentRef['discussion'], value: typeof Api.IssueComment.Type) => - GitHubIssueComment.make({ + GitHubIssueCommentRecord.make({ ref: { discussion, id: value.id }, body: value.body, url: value.html_url, @@ -97,7 +100,7 @@ export const review = (pullRequest: GitHubPullRequestRef, value: typeof Api.Revi url: value.html_url, }) export const reviewComment = (pullRequest: GitHubPullRequestRef, value: typeof Api.ReviewComment.Type) => - GitHubReviewComment.make({ + GitHubReviewCommentRecord.make({ ref: { pullRequest, id: value.id }, nodeId: value.node_id, body: value.body, @@ -170,7 +173,7 @@ export const checkRunInfo = (ref: GitHubCheckRunRef, value: typeof Api.CheckRun. annotationCount: value.output.annotations_count, }) export const checkRun = (repository: GitHubRepositoryRef, value: typeof Api.CheckRun.Type) => - GitHubCheckRun.make({ + GitHubCheckRunRecord.make({ ref: { installationId: repository.installationId, repositoryId: repository.repositoryId, @@ -219,7 +222,7 @@ export const actionsJobInfo = (ref: GitHubActionsJobRef, value: typeof Api.Actio return GitHubActionsJobInfo.make(info) } export const actionsJob = (repository: GitHubRepositoryRef, value: typeof Api.ActionsJob.Type) => - GitHubActionsJob.make({ + GitHubActionsJobRecord.make({ ref: { installationId: repository.installationId, repositoryId: repository.repositoryId, diff --git a/packages/github/test/github-api-capabilities.vi.test.ts b/packages/github/test/github-api-capabilities.vi.test.ts index 5a7977c..0ccd563 100644 --- a/packages/github/test/github-api-capabilities.vi.test.ts +++ b/packages/github/test/github-api-capabilities.vi.test.ts @@ -822,8 +822,7 @@ describe('GitHubApiLive agent capabilities', () => { } }).pipe(Effect.provide(makeLayer(httpClient))) - expect(result.checks).toHaveLength(1) - expect(result.checks[0]?.ref).toEqual(checkRun) + expect(result.checks).toEqual([{ ref: checkRun }]) expect(result.info).toMatchObject({ ref: checkRun, name: 'build', @@ -957,7 +956,7 @@ describe('GitHubApiLive agent capabilities', () => { } }).pipe(Effect.provide(makeLayer(httpClient))) - expect(result.resolved?.ref).toEqual(actionsJob) + expect(result.resolved).toEqual({ ref: actionsJob }) expect(result.info).toMatchObject({ ref: actionsJob, runId: 850, diff --git a/packages/github/test/github-delivery-output.vi.test.ts b/packages/github/test/github-delivery-output.vi.test.ts index b6fab45..89e03ce 100644 --- a/packages/github/test/github-delivery-output.vi.test.ts +++ b/packages/github/test/github-delivery-output.vi.test.ts @@ -41,7 +41,7 @@ import { GitHubDeliveryDestination, GitHubDeliveryDestinationJson, GitHubId, - GitHubIssueComment, + GitHubIssueCommentRecord, GitHubIssueCommentRef, GitHubIssueRef, GitHubOutputReceipt, @@ -126,7 +126,7 @@ const run = ( Effect.andThen(Option.match(failure, { onNone: () => Effect.void, onSome: Effect.fail })), ) const commentOn = (discussion: GitHubIssueCommentRef['discussion'], markdown: string) => - GitHubIssueComment.make({ + GitHubIssueCommentRecord.make({ ref: { discussion, id: GitHubId.make(900) }, body: markdown, url: 'https://github.com/alice/project/issues/42#issuecomment-900', @@ -213,7 +213,7 @@ describe('GitHub delivery output', () => { postIssueComment: ({ issue: target, content }) => Ref.update(calls, (all) => [...all, 'post']).pipe( Effect.as( - GitHubIssueComment.make({ + GitHubIssueCommentRecord.make({ ref: { discussion: { _tag: 'Issue', ref: target }, id: GitHubId.make(901) }, body: content.markdown, url: 'https://github.com/alice/project/issues/42#issuecomment-901', diff --git a/packages/github/test/github-remote-delivery.vi.test.ts b/packages/github/test/github-remote-delivery.vi.test.ts index 15ac16c..b613368 100644 --- a/packages/github/test/github-remote-delivery.vi.test.ts +++ b/packages/github/test/github-remote-delivery.vi.test.ts @@ -25,7 +25,7 @@ import { GitHubApiError, GitHubBot, GitHubId, - GitHubIssueComment, + GitHubIssueCommentRecord, GitHubReviewCommentRef, type GitHubCommentRef, type GitHubDiscussionRef, @@ -77,7 +77,7 @@ const makeFakeGitHub = (options: FakeGitHubOptions = {}) => if (options.during !== undefined && options.during.body === markdown) yield* options.during.run const id = 900 + (yield* Ref.updateAndGet(commentCount, (n) => n + 1)) yield* Queue.offer(shown, `post ${describeDiscussion(discussion)}: ${markdown}`) - return GitHubIssueComment.make({ + return GitHubIssueCommentRecord.make({ ref: { discussion, id: GitHubId.make(id) }, body: markdown, url: `https://github.com/alice/project/issues/42#issuecomment-${id}`, @@ -106,7 +106,7 @@ const makeFakeGitHub = (options: FakeGitHubOptions = {}) => ? Effect.die(new Error('the delivery never edits review comments')) : Queue.offer(shown, `edit ${describeComment(comment)}: ${content.markdown}`).pipe( Effect.as( - GitHubIssueComment.make({ + GitHubIssueCommentRecord.make({ ref: comment, body: content.markdown, url: 'https://github.com/alice/project/issues/42', diff --git a/packages/github/test/github-resources.vi.test.ts b/packages/github/test/github-resources.vi.test.ts index b8290f5..e76d52b 100644 --- a/packages/github/test/github-resources.vi.test.ts +++ b/packages/github/test/github-resources.vi.test.ts @@ -6,9 +6,11 @@ import { GitHubId } from '../src/GitHubIdentity' import { GitHubActionsJobInfo, GitHubActionsJobRef, + GitHubActionsJobRecord, GitHubCheckAnnotation, GitHubCheckRunInfo, GitHubCheckRunRef, + GitHubCheckRunRecord, GitHubCommit, GitHubContent, GitHubIssueInfo, @@ -18,6 +20,7 @@ import { GitHubPullRequestFile, GitHubPullRequestInfo, GitHubPullRequestRef, + GitHubReviewCommentRecord, } from '../src/GitHubModels' import { GitHubActionsJob, @@ -108,7 +111,7 @@ const commits = [ }), ] -const reviewComment = GitHubReviewComment.make({ +const reviewCommentRecord = GitHubReviewCommentRecord.make({ ref: { pullRequest: pullRequestRef, id: GitHubId.make(500) }, nodeId: 'PRRC_500', body: 'Review body', @@ -125,7 +128,7 @@ const reviewComment = GitHubReviewComment.make({ }) const mergeResult = GitHubMergeResult.make({ merged: true, sha: 'merge-sha', message: 'Merged' }) -const checkRuns = [GitHubCheckRun.make({ ref: checkRunRef })] +const checkRunRecords = [GitHubCheckRunRecord.make({ ref: checkRunRef })] const checkRunInfo = GitHubCheckRunInfo.make({ ref: checkRunRef, @@ -160,7 +163,7 @@ const annotations = [ }), ] -const job = GitHubActionsJob.make({ ref: jobRef }) +const jobRecord = GitHubActionsJobRecord.make({ ref: jobRef }) const jobInfo = GitHubActionsJobInfo.make({ ref: jobRef, @@ -238,7 +241,7 @@ describe('GitHub resource capability delegation', () => { const layer = Layer.mock(GitHubApi, { postPullRequestReviewComment: (input) => Queue.offer(calls, { operation: 'postPullRequestReviewComment', input }).pipe( - Effect.as(reviewComment), + Effect.as(reviewCommentRecord), ), listPullRequestFiles: (input) => Queue.offer(calls, { operation: 'listPullRequestFiles', input }).pipe(Effect.as(files)), @@ -259,7 +262,7 @@ describe('GitHub resource capability delegation', () => { fetchPullRequest: (input) => Queue.offer(calls, { operation: 'fetchPullRequest', input }).pipe(Effect.as(pullRequestInfo)), listCheckRunsForRef: (input) => - Queue.offer(calls, { operation: 'listCheckRunsForRef', input }).pipe(Effect.as(checkRuns)), + Queue.offer(calls, { operation: 'listCheckRunsForRef', input }).pipe(Effect.as(checkRunRecords)), closePullRequest: (input) => Queue.offer(calls, { operation: 'closePullRequest', input }).pipe(Effect.as(pullRequestInfo)), reopenPullRequest: (input) => @@ -299,8 +302,12 @@ describe('GitHub resource capability delegation', () => { } }).pipe(Effect.provide(layer)) + const checkRuns = checkRunRecords.map((record) => GitHubCheckRun.make(record)) + expect(results.reviewComment).toBeInstanceOf(GitHubReviewComment) + expect(results.currentChecks[0]).toBeInstanceOf(GitHubCheckRun) + expect(results.exactChecks[0]).toBeInstanceOf(GitHubCheckRun) expect(results).toMatchObject({ - reviewComment, + reviewComment: GitHubReviewComment.make(reviewCommentRecord), files, diff: 'complete diff', commits, @@ -368,7 +375,7 @@ describe('GitHub resource capability delegation', () => { listCheckRunAnnotations: (input) => Queue.offer(calls, { operation: 'listCheckRunAnnotations', input }).pipe(Effect.as(annotations)), resolveActionsJob: (input) => - Queue.offer(calls, { operation: 'resolveActionsJob', input }).pipe(Effect.as(job)), + Queue.offer(calls, { operation: 'resolveActionsJob', input }).pipe(Effect.as(jobRecord)), fetchActionsJob: (input) => Queue.offer(calls, { operation: 'fetchActionsJob', input }).pipe(Effect.as(jobInfo)), downloadActionsJobLog: (input) => @@ -387,7 +394,14 @@ describe('GitHub resource capability delegation', () => { } }).pipe(Effect.provide(layer)) - expect(results).toEqual({ checkInfo: checkRunInfo, annotations, job, jobInfo, log: 'job log' }) + expect(results.job).toBeInstanceOf(GitHubActionsJob) + expect(results).toEqual({ + checkInfo: checkRunInfo, + annotations, + job: GitHubActionsJob.make(jobRecord), + jobInfo, + log: 'job log', + }) expect(Array.from(yield* Queue.takeAll(calls))).toEqual([ { operation: 'fetchCheckRun', input: { checkRun: checkRunRef } }, { operation: 'listCheckRunAnnotations', input: { checkRun: checkRunRef } }, diff --git a/packages/linear/README.md b/packages/linear/README.md index 5afbd6a..e3f5606 100644 --- a/packages/linear/README.md +++ b/packages/linear/README.md @@ -78,6 +78,8 @@ const linearApi = makeLinearApiLive(LinearApiLiveOptions.make({ auth, ...bot })) `auth` stays on the bot because webhooks carry the app's OAuth client ID, which the bot checks. `makeLinearApiLiveBase` and `LinearApiLiveBase` leave `HttpClient` for the application to provide. +`LinearApi` takes and returns plain records: `LinearCommentRecord`, `LinearReactionRecord`, `LinearIssueAttachmentRecord`, and `LinearFileRecord`. The resources on callback events wrap them, so `issue.listComments()` gives `LinearComment`s with methods. Code that calls `LinearApi` itself can wrap a record the same way, as with `LinearComment.fromRecord(record)` or `LinearFile.make(record)`. + ## Files Linear keeps three representations separate: diff --git a/packages/linear/src/LinearApi.ts b/packages/linear/src/LinearApi.ts index fec2978..5e26e35 100644 --- a/packages/linear/src/LinearApi.ts +++ b/packages/linear/src/LinearApi.ts @@ -32,14 +32,13 @@ import { uploadLinearFileBytes, } from './api/Operations' import { LinearApiError, type LinearApiOperations } from './LinearApiContract' -import { linearApiKey } from './LinearApiHandle' import { defaultLinearFileTransferPolicy, type LinearDownloadFileRequest, LinearFileSizeLimitExceeded, type LinearFileTransferPolicy, type LinearUploadFileRequest, -} from './LinearFiles' +} from './LinearFileModels' import type { LinearOrganizationId } from './LinearIdentity' export * from './LinearApiContract' @@ -62,169 +61,174 @@ const uploadedAttachmentSubtitle = (size: number) => `${(size / 1024).toFixed(1) * Provider-native Linear operations used by behavior-bearing Linear resources. The layer sends through the * `LinearHttpClient` and `HttpClient` in context. */ -export class LinearApi extends Context.Service()(linearApiKey, { - make: Effect.fnUntraced(function* (filePolicy: LinearFileTransferPolicy = defaultLinearFileTransferPolicy) { - const client = yield* LinearHttpClient - const http = yield* HttpClient.HttpClient - const executeAuthenticated = ( - organizationId: LinearOrganizationId, - procedure: Effect.Effect, - ): Effect.Effect => { - if (organizationId !== client.organizationId) { - return Effect.fail( - LinearApiError.make({ - operation: 'viewer_identity', - reason: 'identity_mismatch', - retryable: false, - }), - ) - } - return procedure.pipe(Effect.provideService(LinearHttpClient, client), narrowLinearProviderErrors) - } - - /** - * Log a credential Linear refused as a configuration error that names it. A developer token cannot be - * renewed; client credentials were already renewed once. - */ - const reportRejectedCredential = (error: LinearApiError) => - error.reason === 'unauthorized' - ? Effect.logError( - client.canRefresh - ? 'Linear rejected the app client credentials; check LINEAR_CLIENT_ID and LINEAR_CLIENT_SECRET' - : 'Linear rejected the developer token; check LINEAR_DEVELOPER_TOKEN, or remove it to use the app client credentials', - ).pipe( - Effect.annotateLogs({ - provider: 'linear', - credential: client.canRefresh ? 'client_credentials' : 'developer_token', - operation: error.operation, +export class LinearApi extends Context.Service()( + '@humanlayer/channels-linear/LinearApi', + { + make: Effect.fnUntraced(function* (filePolicy: LinearFileTransferPolicy = defaultLinearFileTransferPolicy) { + const client = yield* LinearHttpClient + const http = yield* HttpClient.HttpClient + const executeAuthenticated = ( + organizationId: LinearOrganizationId, + procedure: Effect.Effect, + ): Effect.Effect => { + if (organizationId !== client.organizationId) { + return Effect.fail( + LinearApiError.make({ + operation: 'viewer_identity', + reason: 'identity_mismatch', + retryable: false, }), ) - : Effect.void + } + return procedure.pipe(Effect.provideService(LinearHttpClient, client), narrowLinearProviderErrors) + } - const refreshAuthentication = ( - organizationId: LinearOrganizationId, - procedure: Effect.Effect, - ) => - executeAuthenticated(organizationId, procedure).pipe( - Effect.catchTag('LinearApiError', (error) => { - if (error.reason !== 'unauthorized' || !client.canRefresh) return Effect.fail(error) - return client.invalidateCredential.pipe( - Effect.andThen(executeAuthenticated(organizationId, procedure)), - ) - }), - Effect.tapError(reportRejectedCredential), - ) + /** + * Log a credential Linear refused as a configuration error that names it. A developer token cannot be + * renewed; client credentials were already renewed once. + */ + const reportRejectedCredential = (error: LinearApiError) => + error.reason === 'unauthorized' + ? Effect.logError( + client.canRefresh + ? 'Linear rejected the app client credentials; check LINEAR_CLIENT_ID and LINEAR_CLIENT_SECRET' + : 'Linear rejected the developer token; check LINEAR_DEVELOPER_TOKEN, or remove it to use the app client credentials', + ).pipe( + Effect.annotateLogs({ + provider: 'linear', + credential: client.canRefresh ? 'client_credentials' : 'developer_token', + operation: error.operation, + }), + ) + : Effect.void - const executeRead = ( - organizationId: LinearOrganizationId, - procedure: Effect.Effect, - ) => refreshAuthentication(organizationId, procedure).pipe(Effect.retry(readRetryPolicy)) + const refreshAuthentication = ( + organizationId: LinearOrganizationId, + procedure: Effect.Effect, + ) => + executeAuthenticated(organizationId, procedure).pipe( + Effect.catchTag('LinearApiError', (error) => { + if (error.reason !== 'unauthorized' || !client.canRefresh) return Effect.fail(error) + return client.invalidateCredential.pipe( + Effect.andThen(executeAuthenticated(organizationId, procedure)), + ) + }), + Effect.tapError(reportRejectedCredential), + ) - const executeMutation = refreshAuthentication + const executeRead = ( + organizationId: LinearOrganizationId, + procedure: Effect.Effect, + ) => refreshAuthentication(organizationId, procedure).pipe(Effect.retry(readRetryPolicy)) - const uploadFile = Effect.fn('linear.api_live.upload_file')(function* (request: LinearUploadFileRequest) { - const target = yield* executeMutation(request.issue.organizationId, requestLinearFileUpload(request)) - yield* uploadLinearFileBytes({ - target, - contentType: request.input.contentType, - bytes: request.input.bytes, - }).pipe(Effect.provideService(HttpClient.HttpClient, http), narrowLinearProviderErrors) - const file = projectLinearUploadedFile(request.issue, request.input.filename, target) - yield* Effect.logInfo('Linear file uploaded').pipe( - Effect.annotateLogs({ issue_id: request.issue.issueId, bytes: request.input.bytes.byteLength }), - ) - return file - }) + const executeMutation = refreshAuthentication - const openDownload = (request: LinearDownloadFileRequest) => - executeRead(request.file.organizationId, downloadLinearFile({ request, policy: filePolicy })).pipe( - Effect.map((download) => ({ - contentLength: download.contentLength, - stream: narrowLinearProviderStreamErrors(download.stream), - })), - ) + const uploadFile = Effect.fn('linear.api_live.upload_file')(function* (request: LinearUploadFileRequest) { + const target = yield* executeMutation(request.issue.organizationId, requestLinearFileUpload(request)) + yield* uploadLinearFileBytes({ + target, + contentType: request.input.contentType, + bytes: request.input.bytes, + }).pipe(Effect.provideService(HttpClient.HttpClient, http), narrowLinearProviderErrors) + const file = projectLinearUploadedFile(request.issue, request.input.filename, target) + yield* Effect.logInfo('Linear file uploaded').pipe( + Effect.annotateLogs({ issue_id: request.issue.issueId, bytes: request.input.bytes.byteLength }), + ) + return file + }) - const operations: LinearApiOperations = { - createAgentActivity: (request) => executeMutation(request.organizationId, createAgentActivity(request)), - updateAgentSession: (request) => executeMutation(request.organizationId, updateAgentSession(request)), - getIssue: (request) => executeRead(request.issue.organizationId, getIssue(request)), - updateIssue: (request) => executeMutation(request.issue.organizationId, updateIssue(request)), - listAssignableUsers: (request) => executeRead(request.issue.organizationId, listAssignableUsers(request)), - listAppUsers: (request) => - executeRead( - request.issue.organizationId, - getIssue({ issue: request.issue }).pipe( - Effect.flatMap((resolved) => listAppUsers({ ...request, issue: resolved.ref })), + const openDownload = (request: LinearDownloadFileRequest) => + executeRead(request.file.organizationId, downloadLinearFile({ request, policy: filePolicy })).pipe( + Effect.map((download) => ({ + contentLength: download.contentLength, + stream: narrowLinearProviderStreamErrors(download.stream), + })), + ) + + const operations: LinearApiOperations = { + createAgentActivity: (request) => executeMutation(request.organizationId, createAgentActivity(request)), + updateAgentSession: (request) => executeMutation(request.organizationId, updateAgentSession(request)), + getIssue: (request) => executeRead(request.issue.organizationId, getIssue(request)), + updateIssue: (request) => executeMutation(request.issue.organizationId, updateIssue(request)), + listAssignableUsers: (request) => + executeRead(request.issue.organizationId, listAssignableUsers(request)), + listAppUsers: (request) => + executeRead( + request.issue.organizationId, + getIssue({ issue: request.issue }).pipe( + Effect.flatMap((resolved) => listAppUsers({ ...request, issue: resolved.ref })), + ), ), - ), - getUser: (request) => - executeRead( - request.issue.organizationId, - getIssue({ issue: request.issue }).pipe( - Effect.flatMap((resolved) => getUser({ ...request, issue: resolved.ref })), + getUser: (request) => + executeRead( + request.issue.organizationId, + getIssue({ issue: request.issue }).pipe( + Effect.flatMap((resolved) => getUser({ ...request, issue: resolved.ref })), + ), ), - ), - listIssueComments: (request) => executeRead(request.issue.organizationId, listIssueComments(request)), - listIssueAttachments: (request) => executeRead(request.issue.organizationId, listIssueAttachments(request)), - createComment: (request) => executeMutation(request.issue.organizationId, createComment(request)), - updateComment: (request) => executeMutation(request.comment.organizationId, updateComment(request)), - deleteComment: (request) => executeMutation(request.comment.organizationId, deleteComment(request)), - createReaction: (request) => { - const organizationId = Match.value(request.target).pipe( - Match.tagsExhaustive({ - Issue: ({ issue }) => issue.organizationId, - Comment: ({ comment }) => comment.organizationId, - }), - ) - return executeMutation(organizationId, createReaction(request)) - }, - deleteReaction: (request) => executeMutation(request.issue.organizationId, deleteReaction(request)), - createAttachment: (request) => executeMutation(request.issue.organizationId, createAttachment(request)), - updateAttachment: (request) => - executeMutation(request.attachment.issue.organizationId, updateAttachment(request)), - deleteAttachment: (request) => - executeMutation(request.attachment.issue.organizationId, deleteAttachment(request)), - uploadFile, - uploadAttachment: (request) => - uploadFile({ issue: request.issue, input: request.input }).pipe( - Effect.flatMap((file) => { - const size = request.input.bytes.byteLength - return executeMutation( - request.issue.organizationId, - createAttachment({ - issue: request.issue, - input: { - url: file.url, - title: request.input.title ?? request.input.filename, - subtitle: request.input.subtitle ?? uploadedAttachmentSubtitle(size), - metadata: { - contentType: request.input.contentType, - size, - ...request.input.metadata, + listIssueComments: (request) => executeRead(request.issue.organizationId, listIssueComments(request)), + listIssueAttachments: (request) => + executeRead(request.issue.organizationId, listIssueAttachments(request)), + createComment: (request) => executeMutation(request.issue.organizationId, createComment(request)), + updateComment: (request) => executeMutation(request.comment.organizationId, updateComment(request)), + deleteComment: (request) => executeMutation(request.comment.organizationId, deleteComment(request)), + createReaction: (request) => { + const organizationId = Match.value(request.target).pipe( + Match.tagsExhaustive({ + Issue: ({ issue }) => issue.organizationId, + Comment: ({ comment }) => comment.organizationId, + }), + ) + return executeMutation(organizationId, createReaction(request)) + }, + deleteReaction: (request) => executeMutation(request.issue.organizationId, deleteReaction(request)), + createAttachment: (request) => executeMutation(request.issue.organizationId, createAttachment(request)), + updateAttachment: (request) => + executeMutation(request.attachment.issue.organizationId, updateAttachment(request)), + deleteAttachment: (request) => + executeMutation(request.attachment.issue.organizationId, deleteAttachment(request)), + uploadFile, + uploadAttachment: (request) => + uploadFile({ issue: request.issue, input: request.input }).pipe( + Effect.flatMap((file) => { + const size = request.input.bytes.byteLength + return executeMutation( + request.issue.organizationId, + createAttachment({ + issue: request.issue, + input: { + url: file.url, + title: request.input.title ?? request.input.filename, + subtitle: request.input.subtitle ?? uploadedAttachmentSubtitle(size), + metadata: { + contentType: request.input.contentType, + size, + ...request.input.metadata, + }, }, - }, + }), + ) + }), + ), + downloadFile: (request) => openDownload(request).pipe(Effect.map((download) => download.stream)), + downloadFileBytes: (request) => { + if (request.size !== null && request.size > request.maxBytes) + return Effect.fail( + LinearFileSizeLimitExceeded.make({ + maxBytes: request.maxBytes, + observedBytes: request.size, + source: 'declared_size', }), ) - }), - ), - downloadFile: (request) => openDownload(request).pipe(Effect.map((download) => download.stream)), - downloadFileBytes: (request) => { - if (request.size !== null && request.size > request.maxBytes) - return Effect.fail( - LinearFileSizeLimitExceeded.make({ - maxBytes: request.maxBytes, - observedBytes: request.size, - source: 'declared_size', - }), + return openDownload(request).pipe( + Effect.flatMap((download) => readBoundedLinearFileBytes(download, request.maxBytes)), ) - return openDownload(request).pipe( - Effect.flatMap((download) => readBoundedLinearFileBytes(download, request.maxBytes)), - ) - }, - } - return operations - }), -}) { + }, + } + return operations + }), + }, +) { static readonly layer = (filePolicy: LinearFileTransferPolicy = defaultLinearFileTransferPolicy) => Layer.effect(this, this.make(filePolicy)) } diff --git a/packages/linear/src/LinearApiContract.ts b/packages/linear/src/LinearApiContract.ts index 826f0e8..a7f4fef 100644 --- a/packages/linear/src/LinearApiContract.ts +++ b/packages/linear/src/LinearApiContract.ts @@ -4,16 +4,15 @@ */ import { type Effect, Schema, type Stream } from 'effect' -import type { - LinearDownloadFileBytesRequest, - LinearDownloadFileRequest, - LinearFile, - LinearFileError, - LinearUploadAttachmentRequest, - LinearUploadFileRequest, -} from './LinearFiles' import { - LinearAttachmentId, + type LinearDownloadFileBytesRequest, + type LinearDownloadFileRequest, + type LinearFileRecord, + LinearFileSizeLimitExceeded, + type LinearUploadAttachmentRequest, + type LinearUploadFileRequest, +} from './LinearFileModels' +import { LinearCommentId, LinearIssueLabelId, LinearReactionId, @@ -23,6 +22,7 @@ import { import { LinearAgentActivityReceipt, LinearAppUserPage, + LinearAttachmentRef, LinearCommentRef, LinearContent, LinearCreateAgentActivityRequest, @@ -32,7 +32,7 @@ import { LinearUser, LinearUserPage, } from './LinearModels' -import type { LinearComment, LinearIssueAttachment, LinearReaction } from './LinearResources' +import type { LinearCommentRecord, LinearIssueAttachmentRecord, LinearReactionRecord } from './LinearResourceRecords' export const LinearIssueRequest = Schema.Struct({ issue: LinearIssueRef }) export type LinearIssueRequest = typeof LinearIssueRequest.Type @@ -109,8 +109,6 @@ export const LinearCreateAttachmentRequest = Schema.Struct({ input: LinearCreateAttachmentInput, }) export type LinearCreateAttachmentRequest = typeof LinearCreateAttachmentRequest.Type -export const LinearAttachmentRef = Schema.Struct({ issue: LinearIssueRef, attachmentId: LinearAttachmentId }) -export type LinearAttachmentRef = typeof LinearAttachmentRef.Type export const LinearUpdateAttachmentInput = Schema.Struct({ title: Schema.String, subtitle: Schema.optionalKey(Schema.NullOr(Schema.String)), @@ -177,6 +175,9 @@ export class LinearApiError extends Schema.TaggedError()('Linear retryAfterMs: Schema.optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))), }) {} +export const LinearFileError = Schema.Union([LinearApiError, LinearFileSizeLimitExceeded]) +export type LinearFileError = typeof LinearFileError.Type + /** The operations `LinearApi` provides. */ export type LinearApiOperations = { readonly createAgentActivity: ( @@ -192,26 +193,28 @@ export type LinearApiOperations = { readonly getUser: (request: LinearGetUserRequest) => Effect.Effect readonly listIssueComments: ( request: LinearIssueRequest, - ) => Effect.Effect, LinearApiError> + ) => Effect.Effect, LinearApiError> readonly listIssueAttachments: ( request: LinearIssueRequest, - ) => Effect.Effect, LinearApiError> - readonly createComment: (request: LinearCreateCommentRequest) => Effect.Effect - readonly updateComment: (request: LinearUpdateCommentRequest) => Effect.Effect + ) => Effect.Effect, LinearApiError> + readonly createComment: (request: LinearCreateCommentRequest) => Effect.Effect + readonly updateComment: (request: LinearUpdateCommentRequest) => Effect.Effect readonly deleteComment: (request: LinearDeleteCommentRequest) => Effect.Effect - readonly createReaction: (request: LinearCreateReactionRequest) => Effect.Effect + readonly createReaction: ( + request: LinearCreateReactionRequest, + ) => Effect.Effect readonly deleteReaction: (request: LinearDeleteReactionRequest) => Effect.Effect readonly createAttachment: ( request: LinearCreateAttachmentRequest, - ) => Effect.Effect + ) => Effect.Effect readonly updateAttachment: ( request: LinearUpdateAttachmentRequest, - ) => Effect.Effect + ) => Effect.Effect readonly deleteAttachment: (request: LinearDeleteAttachmentRequest) => Effect.Effect - readonly uploadFile: (request: LinearUploadFileRequest) => Effect.Effect + readonly uploadFile: (request: LinearUploadFileRequest) => Effect.Effect readonly uploadAttachment: ( request: LinearUploadAttachmentRequest, - ) => Effect.Effect + ) => Effect.Effect readonly downloadFile: ( request: LinearDownloadFileRequest, ) => Effect.Effect, LinearApiError> diff --git a/packages/linear/src/LinearApiHandle.ts b/packages/linear/src/LinearApiHandle.ts deleted file mode 100644 index d6b6555..0000000 --- a/packages/linear/src/LinearApiHandle.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * A second handle on the `LinearApi` service. It shares the service's key, so it reads the same slot in - * context. Linear files and resources read the service through it: importing `LinearApi` itself would form an - * import cycle, since `LinearApi`'s constructor builds those files and resources. - */ -import { Context } from 'effect' - -import type { LinearApi } from './LinearApi' -import type { LinearApiOperations } from './LinearApiContract' - -export const linearApiKey = '@humanlayer/channels-linear/LinearApi' - -export const LinearApiHandle = Context.Service(linearApiKey) diff --git a/packages/linear/src/LinearApiLive.ts b/packages/linear/src/LinearApiLive.ts index 0ec3f36..060c50e 100644 --- a/packages/linear/src/LinearApiLive.ts +++ b/packages/linear/src/LinearApiLive.ts @@ -9,7 +9,7 @@ import { LinearCredentialResolverClientCredentials, LinearCredentialResolverDeveloperToken, } from './LinearCredentialResolver' -import { LinearFileTransferPolicy } from './LinearFiles' +import { LinearFileTransferPolicy } from './LinearFileModels' import { LinearOrganizationId, LinearUserId } from './LinearIdentity' const LinearOrganizationIdConfig = Schema.declare>( diff --git a/packages/linear/src/LinearEventProcessor.ts b/packages/linear/src/LinearEventProcessor.ts index f552f61..b4a9f34 100644 --- a/packages/linear/src/LinearEventProcessor.ts +++ b/packages/linear/src/LinearEventProcessor.ts @@ -65,7 +65,8 @@ import { LinearIssueDestination, encodeLinearDeliveryPreparation, } from './LinearDeliveryDestination' -import { discoverLinearFiles, LinearFileRef } from './LinearFiles' +import { LinearFileRef } from './LinearFileModels' +import { discoverLinearFiles } from './LinearFiles' import { LinearAgentActivityId, LinearAgentSessionId, diff --git a/packages/linear/src/LinearFileModels.ts b/packages/linear/src/LinearFileModels.ts new file mode 100644 index 0000000..01058b6 --- /dev/null +++ b/packages/linear/src/LinearFileModels.ts @@ -0,0 +1,239 @@ +import { Match, Predicate, Schema } from 'effect' +import type { Nodes } from 'mdast' +import { fromMarkdown } from 'mdast-util-from-markdown' +import { gfmFromMarkdown } from 'mdast-util-gfm' +import { gfm } from 'micromark-extension-gfm' + +import { LinearIssueId, LinearOrganizationId } from './LinearIdentity' +import { LinearIssueRef } from './LinearModels' + +/** The only origin that receives the workspace credential for file downloads. */ +export const linearFileOrigin = 'https://uploads.linear.app' + +/** Upper bound for one in-memory upload; Linear's `fileUpload` requires the exact byte size up front. */ +export const linearMaxUploadBytes = 50 * 1024 * 1024 + +const linearFilePathSegment = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/ + +const isLinearFilePathSegment = (segment: string) => linearFilePathSegment.test(segment) && !segment.includes('..') + +/** + * Returns the canonical `https://uploads.linear.app///` form of a Linear upload URL, or + * null for every other URL, including lookalike hosts, other schemes, credentials, ports, and non-canonical paths. + */ +export const canonicalLinearFileUrl = (value: string): string | null => { + if (!URL.canParse(value)) return null + const url = new URL(value) + if (url.origin !== linearFileOrigin || url.username !== '' || url.password !== '') return null + const segments = url.pathname.split('/').slice(1) + if (segments.length !== 3 || !segments.every(isLinearFilePathSegment)) return null + return `${url.origin}${url.pathname}` +} + +/** True only for HTTPS URLs on the approved Linear upload origin. */ +export const isLinearFileOrigin = (value: string) => URL.canParse(value) && new URL(value).origin === linearFileOrigin + +export const LinearFileUrl = Schema.String.check( + Schema.makeFilter((value: string) => + canonicalLinearFileUrl(value) === value ? undefined : 'expected a canonical uploads.linear.app file URL', + ), +).pipe(Schema.brand('LinearFileUrl')) +export type LinearFileUrl = typeof LinearFileUrl.Type + +export const LinearFileRef = Schema.Struct({ + organizationId: LinearOrganizationId, + issueId: LinearIssueId, +}) +export type LinearFileRef = typeof LinearFileRef.Type + +export const LinearByteCount = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)) + +export const LinearDownloadBytesOptions = Schema.Struct({ + maxBytes: Schema.Int.check(Schema.isGreaterThan(0)), +}) +export type LinearDownloadBytesOptions = typeof LinearDownloadBytesOptions.Type + +export const LinearDownloadFileRequest = Schema.Struct({ + file: LinearFileRef, + url: LinearFileUrl, + size: Schema.NullOr(LinearByteCount), +}) +export type LinearDownloadFileRequest = typeof LinearDownloadFileRequest.Type + +export const LinearDownloadFileBytesRequest = Schema.Struct({ + ...LinearDownloadFileRequest.fields, + ...LinearDownloadBytesOptions.fields, +}) +export type LinearDownloadFileBytesRequest = typeof LinearDownloadFileBytesRequest.Type + +export const LinearUploadBytes = Schema.Uint8Array.check( + Schema.makeFilter((bytes: Uint8Array) => + bytes.byteLength <= linearMaxUploadBytes ? undefined : `expected at most ${linearMaxUploadBytes} bytes`, + ), +) + +export const LinearUploadFileInput = Schema.Struct({ + filename: Schema.NonEmptyString, + contentType: Schema.NonEmptyString, + bytes: LinearUploadBytes, +}) +export type LinearUploadFileInput = typeof LinearUploadFileInput.Type + +export const LinearUploadFileRequest = Schema.Struct({ issue: LinearIssueRef, input: LinearUploadFileInput }) +export type LinearUploadFileRequest = typeof LinearUploadFileRequest.Type + +export const LinearUploadAttachmentInput = Schema.Struct({ + ...LinearUploadFileInput.fields, + title: Schema.optionalKey(Schema.String), + subtitle: Schema.optionalKey(Schema.String), + metadata: Schema.optionalKey(Schema.Record(Schema.String, Schema.Json)), +}) +export type LinearUploadAttachmentInput = typeof LinearUploadAttachmentInput.Type + +export const LinearUploadAttachmentRequest = Schema.Struct({ + issue: LinearIssueRef, + input: LinearUploadAttachmentInput, +}) +export type LinearUploadAttachmentRequest = typeof LinearUploadAttachmentRequest.Type + +/** Transport limits for authenticated downloads. Redirects are followed manually and never inherit credentials. */ +export const LinearFileTransferPolicy = Schema.Struct({ + maxRedirects: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(10)), + timeoutMs: Schema.Int.check(Schema.isGreaterThan(0)), +}) +export type LinearFileTransferPolicy = typeof LinearFileTransferPolicy.Type + +export const defaultLinearFileTransferPolicy = LinearFileTransferPolicy.make({ maxRedirects: 3, timeoutMs: 60_000 }) + +export class LinearFileSizeLimitExceeded extends Schema.TaggedError()( + 'LinearFileSizeLimitExceeded', + { + maxBytes: Schema.Int, + observedBytes: LinearByteCount, + source: Schema.Literals(['declared_size', 'content_length', 'received_bytes']), + }, +) {} + +const contentTypeByExtension = new Map([ + ['csv', 'text/csv'], + ['gif', 'image/gif'], + ['gz', 'application/gzip'], + ['html', 'text/html'], + ['jpeg', 'image/jpeg'], + ['jpg', 'image/jpeg'], + ['json', 'application/json'], + ['log', 'text/plain'], + ['md', 'text/markdown'], + ['mov', 'video/quicktime'], + ['mp3', 'audio/mpeg'], + ['mp4', 'video/mp4'], + ['pdf', 'application/pdf'], + ['png', 'image/png'], + ['svg', 'image/svg+xml'], + ['txt', 'text/plain'], + ['wav', 'audio/wav'], + ['webm', 'video/webm'], + ['webp', 'image/webp'], + ['xml', 'application/xml'], + ['yaml', 'application/yaml'], + ['yml', 'application/yaml'], + ['zip', 'application/zip'], +]) + +const contentTypeFromName = (name: string | null) => { + if (name === null) return null + const extension = /\.([A-Za-z0-9]+)$/.exec(name)?.[1]?.toLowerCase() + if (Predicate.isUndefined(extension)) return null + return contentTypeByExtension.get(extension) ?? null +} + +type LinearFileCandidate = { + readonly url: string + readonly label: string | null +} + +const childrenOf = (node: Nodes): ReadonlyArray => + Match.value(node).pipe( + Match.when({ children: Match.defined }, (parent) => parent.children), + Match.orElse(() => []), + ) + +const textOf = (node: Nodes): string => + Match.value(node).pipe( + Match.discriminator('type')('text', 'inlineCode', (literal) => literal.value), + Match.discriminator('type')('image', (image) => image.alt ?? ''), + Match.orElse((other) => childrenOf(other).map(textOf).join('')), + ) + +const safeLabel = (label: string | null | undefined, url: string) => { + const trimmed = label?.trim() ?? '' + if (trimmed.length === 0 || trimmed === url) return null + return trimmed.slice(0, 255) +} + +const collectDefinitions = (node: Nodes, definitions: Map) => { + if (node.type === 'definition' && !definitions.has(node.identifier)) definitions.set(node.identifier, node.url) + for (const child of childrenOf(node)) collectDefinitions(child, definitions) +} + +const candidatesOf = (node: Nodes, definitions: ReadonlyMap): ReadonlyArray => + Match.value(node).pipe( + Match.discriminator('type')('link', (link) => [{ url: link.url, label: safeLabel(textOf(link), link.url) }]), + Match.discriminator('type')('image', (image) => [{ url: image.url, label: safeLabel(image.alt, image.url) }]), + Match.discriminator('type')('linkReference', 'imageReference', (reference) => { + const url = definitions.get(reference.identifier) + return Predicate.isUndefined(url) ? [] : [{ url, label: safeLabel(textOf(reference), url) }] + }), + Match.orElse(() => []), + ) + +const collectCandidates = ( + node: Nodes, + definitions: ReadonlyMap, + candidates: Array, +) => { + candidates.push(...candidatesOf(node, definitions)) + for (const child of childrenOf(node)) collectCandidates(child, definitions, candidates) +} + +/** A Linear-hosted file as plain data. `LinearFile` adds the methods that download it. */ +export const LinearFileRecord = Schema.Struct({ + ref: LinearFileRef, + url: LinearFileUrl, + name: Schema.NullOr(Schema.String), + contentType: Schema.NullOr(Schema.String), + size: Schema.NullOr(LinearByteCount), +}) +export type LinearFileRecord = typeof LinearFileRecord.Type + +/** + * Finds Linear-hosted files linked or embedded in Markdown. Links, images, reference links, autolinks, and GFM bare + * URLs are parsed structurally; only canonical `uploads.linear.app` URLs become files, in first-occurrence order. + */ +export const discoverLinearFileRecords = ( + ref: LinearFileRef, + markdown: string | null, +): ReadonlyArray => { + if (markdown === null || !markdown.toLowerCase().includes('uploads.linear.app')) return [] + const tree = fromMarkdown(markdown, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] }) + const definitions = new Map() + collectDefinitions(tree, definitions) + const candidates: Array = [] + collectCandidates(tree, definitions, candidates) + const files = new Map() + for (const candidate of candidates) { + const url = canonicalLinearFileUrl(candidate.url) + if (url === null || files.has(url)) continue + files.set( + url, + LinearFileRecord.make({ + ref, + url: LinearFileUrl.make(url), + name: candidate.label, + contentType: contentTypeFromName(candidate.label), + size: null, + }), + ) + } + return Array.from(files.values()) +} diff --git a/packages/linear/src/LinearFiles.ts b/packages/linear/src/LinearFiles.ts index 4c2cc1d..c771792 100644 --- a/packages/linear/src/LinearFiles.ts +++ b/packages/linear/src/LinearFiles.ts @@ -1,224 +1,27 @@ -import { Effect, Match, Predicate, Schema, type Stream } from 'effect' -import type { Nodes } from 'mdast' -import { fromMarkdown } from 'mdast-util-from-markdown' -import { gfmFromMarkdown } from 'mdast-util-gfm' -import { gfm } from 'micromark-extension-gfm' - -import type { LinearApi } from './LinearApi' -import { LinearApiError } from './LinearApiContract' -import { LinearApiHandle } from './LinearApiHandle' -import { LinearIssueId, LinearOrganizationId } from './LinearIdentity' -import { LinearIssueRef } from './LinearModels' - -/** The only origin that receives the workspace credential for file downloads. */ -export const linearFileOrigin = 'https://uploads.linear.app' - -/** Upper bound for one in-memory upload; Linear's `fileUpload` requires the exact byte size up front. */ -export const linearMaxUploadBytes = 50 * 1024 * 1024 - -const linearFilePathSegment = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/ - -const isLinearFilePathSegment = (segment: string) => linearFilePathSegment.test(segment) && !segment.includes('..') - -/** - * Returns the canonical `https://uploads.linear.app///` form of a Linear upload URL, or - * null for every other URL, including lookalike hosts, other schemes, credentials, ports, and non-canonical paths. - */ -export const canonicalLinearFileUrl = (value: string): string | null => { - if (!URL.canParse(value)) return null - const url = new URL(value) - if (url.origin !== linearFileOrigin || url.username !== '' || url.password !== '') return null - const segments = url.pathname.split('/').slice(1) - if (segments.length !== 3 || !segments.every(isLinearFilePathSegment)) return null - return `${url.origin}${url.pathname}` -} - -/** True only for HTTPS URLs on the approved Linear upload origin. */ -export const isLinearFileOrigin = (value: string) => URL.canParse(value) && new URL(value).origin === linearFileOrigin - -export const LinearFileUrl = Schema.String.check( - Schema.makeFilter((value: string) => - canonicalLinearFileUrl(value) === value ? undefined : 'expected a canonical uploads.linear.app file URL', - ), -).pipe(Schema.brand('LinearFileUrl')) -export type LinearFileUrl = typeof LinearFileUrl.Type - -export const LinearFileRef = Schema.Struct({ - organizationId: LinearOrganizationId, - issueId: LinearIssueId, -}) -export type LinearFileRef = typeof LinearFileRef.Type - -export const LinearByteCount = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)) - -export const LinearDownloadBytesOptions = Schema.Struct({ - maxBytes: Schema.Int.check(Schema.isGreaterThan(0)), -}) -export type LinearDownloadBytesOptions = typeof LinearDownloadBytesOptions.Type - -export const LinearDownloadFileRequest = Schema.Struct({ - file: LinearFileRef, - url: LinearFileUrl, - size: Schema.NullOr(LinearByteCount), -}) -export type LinearDownloadFileRequest = typeof LinearDownloadFileRequest.Type - -export const LinearDownloadFileBytesRequest = Schema.Struct({ - ...LinearDownloadFileRequest.fields, - ...LinearDownloadBytesOptions.fields, -}) -export type LinearDownloadFileBytesRequest = typeof LinearDownloadFileBytesRequest.Type - -export const LinearUploadBytes = Schema.Uint8Array.check( - Schema.makeFilter((bytes: Uint8Array) => - bytes.byteLength <= linearMaxUploadBytes ? undefined : `expected at most ${linearMaxUploadBytes} bytes`, - ), -) - -export const LinearUploadFileInput = Schema.Struct({ - filename: Schema.NonEmptyString, - contentType: Schema.NonEmptyString, - bytes: LinearUploadBytes, -}) -export type LinearUploadFileInput = typeof LinearUploadFileInput.Type - -export const LinearUploadFileRequest = Schema.Struct({ issue: LinearIssueRef, input: LinearUploadFileInput }) -export type LinearUploadFileRequest = typeof LinearUploadFileRequest.Type - -export const LinearUploadAttachmentInput = Schema.Struct({ - ...LinearUploadFileInput.fields, - title: Schema.optionalKey(Schema.String), - subtitle: Schema.optionalKey(Schema.String), - metadata: Schema.optionalKey(Schema.Record(Schema.String, Schema.Json)), -}) -export type LinearUploadAttachmentInput = typeof LinearUploadAttachmentInput.Type - -export const LinearUploadAttachmentRequest = Schema.Struct({ - issue: LinearIssueRef, - input: LinearUploadAttachmentInput, -}) -export type LinearUploadAttachmentRequest = typeof LinearUploadAttachmentRequest.Type - -/** Transport limits for authenticated downloads. Redirects are followed manually and never inherit credentials. */ -export const LinearFileTransferPolicy = Schema.Struct({ - maxRedirects: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(10)), - timeoutMs: Schema.Int.check(Schema.isGreaterThan(0)), -}) -export type LinearFileTransferPolicy = typeof LinearFileTransferPolicy.Type - -export const defaultLinearFileTransferPolicy = LinearFileTransferPolicy.make({ maxRedirects: 3, timeoutMs: 60_000 }) - -export class LinearFileSizeLimitExceeded extends Schema.TaggedError()( - 'LinearFileSizeLimitExceeded', - { - maxBytes: Schema.Int, - observedBytes: LinearByteCount, - source: Schema.Literals(['declared_size', 'content_length', 'received_bytes']), - }, -) {} - -export const LinearFileError = Schema.Union([LinearApiError, LinearFileSizeLimitExceeded]) -export type LinearFileError = typeof LinearFileError.Type - -const contentTypeByExtension = new Map([ - ['csv', 'text/csv'], - ['gif', 'image/gif'], - ['gz', 'application/gzip'], - ['html', 'text/html'], - ['jpeg', 'image/jpeg'], - ['jpg', 'image/jpeg'], - ['json', 'application/json'], - ['log', 'text/plain'], - ['md', 'text/markdown'], - ['mov', 'video/quicktime'], - ['mp3', 'audio/mpeg'], - ['mp4', 'video/mp4'], - ['pdf', 'application/pdf'], - ['png', 'image/png'], - ['svg', 'image/svg+xml'], - ['txt', 'text/plain'], - ['wav', 'audio/wav'], - ['webm', 'video/webm'], - ['webp', 'image/webp'], - ['xml', 'application/xml'], - ['yaml', 'application/yaml'], - ['yml', 'application/yaml'], - ['zip', 'application/zip'], -]) - -const contentTypeFromName = (name: string | null) => { - if (name === null) return null - const extension = /\.([A-Za-z0-9]+)$/.exec(name)?.[1]?.toLowerCase() - if (Predicate.isUndefined(extension)) return null - return contentTypeByExtension.get(extension) ?? null -} - -type LinearFileCandidate = { - readonly url: string - readonly label: string | null -} - -const childrenOf = (node: Nodes): ReadonlyArray => - Match.value(node).pipe( - Match.when({ children: Match.defined }, (parent) => parent.children), - Match.orElse(() => []), - ) - -const textOf = (node: Nodes): string => - Match.value(node).pipe( - Match.discriminator('type')('text', 'inlineCode', (literal) => literal.value), - Match.discriminator('type')('image', (image) => image.alt ?? ''), - Match.orElse((other) => childrenOf(other).map(textOf).join('')), - ) - -const safeLabel = (label: string | null | undefined, url: string) => { - const trimmed = label?.trim() ?? '' - if (trimmed.length === 0 || trimmed === url) return null - return trimmed.slice(0, 255) -} - -const collectDefinitions = (node: Nodes, definitions: Map) => { - if (node.type === 'definition' && !definitions.has(node.identifier)) definitions.set(node.identifier, node.url) - for (const child of childrenOf(node)) collectDefinitions(child, definitions) -} - -const candidatesOf = (node: Nodes, definitions: ReadonlyMap): ReadonlyArray => - Match.value(node).pipe( - Match.discriminator('type')('link', (link) => [{ url: link.url, label: safeLabel(textOf(link), link.url) }]), - Match.discriminator('type')('image', (image) => [{ url: image.url, label: safeLabel(image.alt, image.url) }]), - Match.discriminator('type')('linkReference', 'imageReference', (reference) => { - const url = definitions.get(reference.identifier) - return Predicate.isUndefined(url) ? [] : [{ url, label: safeLabel(textOf(reference), url) }] - }), - Match.orElse(() => []), - ) - -const collectCandidates = ( - node: Nodes, - definitions: ReadonlyMap, - candidates: Array, -) => { - candidates.push(...candidatesOf(node, definitions)) - for (const child of childrenOf(node)) collectCandidates(child, definitions, candidates) -} - -export class LinearFile extends Schema.TaggedClass()('LinearFile', { - ref: LinearFileRef, - url: LinearFileUrl, - name: Schema.NullOr(Schema.String), - contentType: Schema.NullOr(Schema.String), - size: Schema.NullOr(LinearByteCount), -}) { +import { Effect, Schema, type Stream } from 'effect' + +import { LinearApi, type LinearApiError, type LinearFileError } from './LinearApi' +import { + discoverLinearFileRecords, + type LinearDownloadBytesOptions, + LinearDownloadFileBytesRequest, + LinearDownloadFileRequest, + LinearFileRecord, + type LinearFileRef, +} from './LinearFileModels' + +/** A Linear-hosted file whose methods download it with the workspace credential. */ +export class LinearFile extends Schema.TaggedClass()('LinearFile', LinearFileRecord.fields) { /** Streams the file with the workspace credential. The stream is not buffered. */ download(): Effect.Effect, LinearApiError, LinearApi> { - return Effect.flatMap(LinearApiHandle, (api) => + return Effect.flatMap(LinearApi, (api) => api.downloadFile(LinearDownloadFileRequest.make({ file: this.ref, url: this.url, size: this.size })), ).pipe(Effect.withSpan('linear.file.download', { attributes: { 'linear.issue_id': this.ref.issueId } })) } /** Buffers the file, failing before `maxBytes` is exceeded. */ downloadBytes(options: LinearDownloadBytesOptions): Effect.Effect { - return Effect.flatMap(LinearApiHandle, (api) => + return Effect.flatMap(LinearApi, (api) => api.downloadFileBytes( LinearDownloadFileBytesRequest.make({ file: this.ref, @@ -236,30 +39,7 @@ export class LinearFile extends Schema.TaggedClass()('LinearFile', { } /** - * Finds Linear-hosted files linked or embedded in Markdown. Links, images, reference links, autolinks, and GFM bare - * URLs are parsed structurally; only canonical `uploads.linear.app` URLs become files, in first-occurrence order. + * Finds Linear-hosted files linked or embedded in Markdown, as `LinearFile`s. See `discoverLinearFileRecords`. */ -export const discoverLinearFiles = (ref: LinearFileRef, markdown: string | null): ReadonlyArray => { - if (markdown === null || !markdown.toLowerCase().includes('uploads.linear.app')) return [] - const tree = fromMarkdown(markdown, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] }) - const definitions = new Map() - collectDefinitions(tree, definitions) - const candidates: Array = [] - collectCandidates(tree, definitions, candidates) - const files = new Map() - for (const candidate of candidates) { - const url = canonicalLinearFileUrl(candidate.url) - if (url === null || files.has(url)) continue - files.set( - url, - LinearFile.make({ - ref, - url: LinearFileUrl.make(url), - name: candidate.label, - contentType: contentTypeFromName(candidate.label), - size: null, - }), - ) - } - return Array.from(files.values()) -} +export const discoverLinearFiles = (ref: LinearFileRef, markdown: string | null): ReadonlyArray => + discoverLinearFileRecords(ref, markdown).map((record) => LinearFile.make(record)) diff --git a/packages/linear/src/LinearModels.ts b/packages/linear/src/LinearModels.ts index 84e3fba..f532f2d 100644 --- a/packages/linear/src/LinearModels.ts +++ b/packages/linear/src/LinearModels.ts @@ -93,6 +93,9 @@ export const LinearIssueAttachmentSnapshot = Schema.Struct({ }) export type LinearIssueAttachmentSnapshot = typeof LinearIssueAttachmentSnapshot.Type +export const LinearAttachmentRef = Schema.Struct({ issue: LinearIssueRef, attachmentId: LinearAttachmentId }) +export type LinearAttachmentRef = typeof LinearAttachmentRef.Type + export const LinearWorkflowState = Schema.Struct({ id: LinearWorkflowStateId, name: Schema.String, diff --git a/packages/linear/src/LinearResourceRecords.ts b/packages/linear/src/LinearResourceRecords.ts new file mode 100644 index 0000000..154ca04 --- /dev/null +++ b/packages/linear/src/LinearResourceRecords.ts @@ -0,0 +1,36 @@ +/** + * The comments, reactions, and attachments `LinearApi` returns, as plain data. `LinearComment`, + * `LinearReaction`, and `LinearIssueAttachment` hold the same fields and add the methods that act on them. + */ +import { Schema } from 'effect' + +import { LinearFileRecord } from './LinearFileModels' +import { LinearReactionId } from './LinearIdentity' +import { + LinearAttachmentRef, + LinearCommentSnapshot, + LinearIssueAttachmentSnapshot, + LinearIssueRef, + LinearReactionSnapshot, +} from './LinearModels' + +export const LinearCommentRecord = Schema.Struct({ + ...LinearCommentSnapshot.fields, + files: Schema.Array(LinearFileRecord), +}) +export type LinearCommentRecord = typeof LinearCommentRecord.Type + +export const LinearReactionRef = Schema.Struct({ issue: LinearIssueRef, reactionId: LinearReactionId }) +export type LinearReactionRef = typeof LinearReactionRef.Type + +export const LinearReactionRecord = Schema.Struct({ + ...LinearReactionSnapshot.fields, + ref: LinearReactionRef, +}) +export type LinearReactionRecord = typeof LinearReactionRecord.Type + +export const LinearIssueAttachmentRecord = Schema.Struct({ + ...LinearIssueAttachmentSnapshot.fields, + ref: LinearAttachmentRef, +}) +export type LinearIssueAttachmentRecord = typeof LinearIssueAttachmentRecord.Type diff --git a/packages/linear/src/LinearResources.ts b/packages/linear/src/LinearResources.ts index 0dea374..c14b23f 100644 --- a/packages/linear/src/LinearResources.ts +++ b/packages/linear/src/LinearResources.ts @@ -5,10 +5,9 @@ import { } from '@humanlayer/channels-delivery' import { Effect, Predicate, Schema } from 'effect' -import type { LinearApi } from './LinearApi' import { + LinearApi, type LinearApiError, - LinearAttachmentRef, LinearCreateAttachmentRequest, type LinearCreateAttachmentInput, LinearCreateCommentRequest, @@ -26,17 +25,15 @@ import { LinearUpdateCommentRequest, LinearUpdateIssueRequest, type LinearUserPageOptions, -} from './LinearApiContract' -import { LinearApiHandle } from './LinearApiHandle' +} from './LinearApi' import { - LinearFile, LinearUploadAttachmentRequest, LinearUploadFileRequest, type LinearUploadAttachmentInput, type LinearUploadFileInput, -} from './LinearFiles' +} from './LinearFileModels' +import { LinearFile } from './LinearFiles' import type { LinearAgentActivityId, LinearIssueLabelId, LinearUserId, LinearWorkflowStateId } from './LinearIdentity' -import { LinearReactionId } from './LinearIdentity' import { LinearWebhookDeliveryId } from './LinearIdentity' import { LinearActivityContent, @@ -46,14 +43,13 @@ import { LinearContent, LinearCreateAgentActivityRequest, LinearIssueSnapshot, - LinearIssueRef, - LinearIssueAttachmentSnapshot, - LinearReactionSnapshot, + type LinearIssueRef, type LinearAppUserPage, type LinearIssueInfo, type LinearUser, type LinearUserPage, } from './LinearModels' +import { type LinearCommentRecord, LinearIssueAttachmentRecord, LinearReactionRecord } from './LinearResourceRecords' const issueAttributes = (issueId: string) => ({ 'linear.issue_id': issueId }) @@ -84,40 +80,36 @@ export class LinearIssue extends Schema.TaggedClass()('LinearIssue' } fetchInfo(): Effect.Effect { - return Effect.flatMap(LinearApiHandle, (api) => - api.getIssue(LinearIssueRequest.make({ issue: this.ref })), - ).pipe(Effect.withSpan('linear.issue.fetch_info', { attributes: issueAttributes(this.ref.issueId) })) + return Effect.flatMap(LinearApi, (api) => api.getIssue(LinearIssueRequest.make({ issue: this.ref }))).pipe( + Effect.withSpan('linear.issue.fetch_info', { attributes: issueAttributes(this.ref.issueId) }), + ) } listAssignableUsers(options: LinearUserPageOptions = {}): Effect.Effect { - return Effect.flatMap(LinearApiHandle, (api) => - api.listAssignableUsers(linearListUsersRequest(this.ref, options)), - ) + return Effect.flatMap(LinearApi, (api) => api.listAssignableUsers(linearListUsersRequest(this.ref, options))) } listAppUsers(options: LinearUserPageOptions = {}): Effect.Effect { - return Effect.flatMap(LinearApiHandle, (api) => api.listAppUsers(linearListUsersRequest(this.ref, options))) + return Effect.flatMap(LinearApi, (api) => api.listAppUsers(linearListUsersRequest(this.ref, options))) } getUser(userId: LinearUserId): Effect.Effect { - return Effect.flatMap(LinearApiHandle, (api) => - api.getUser(LinearGetUserRequest.make({ issue: this.ref, userId })), - ) + return Effect.flatMap(LinearApi, (api) => api.getUser(LinearGetUserRequest.make({ issue: this.ref, userId }))) } listComments(): Effect.Effect, LinearApiError, LinearApi> { - return Effect.flatMap(LinearApiHandle, (api) => + return Effect.flatMap(LinearApi, (api) => api.listIssueComments(LinearIssueRequest.make({ issue: this.ref })), - ) + ).pipe(Effect.map((records) => records.map((record) => LinearComment.fromRecord(record)))) } listAttachments(): Effect.Effect, LinearApiError, LinearApi> { - return Effect.flatMap(LinearApiHandle, (api) => + return Effect.flatMap(LinearApi, (api) => api.listIssueAttachments(LinearIssueRequest.make({ issue: this.ref })), - ) + ).pipe(Effect.map((records) => records.map((record) => LinearIssueAttachment.make(record)))) } postComment(content: LinearContent): Effect.Effect { - return Effect.flatMap(LinearApiHandle, (api) => + return Effect.flatMap(LinearApi, (api) => api.createComment(LinearCreateCommentRequest.make({ issue: this.ref, content })), - ) + ).pipe(Effect.map((record) => LinearComment.fromRecord(record))) } update(update: LinearIssueUpdate): Effect.Effect { - return Effect.flatMap(LinearApiHandle, (api) => + return Effect.flatMap(LinearApi, (api) => api.updateIssue(LinearUpdateIssueRequest.make({ issue: this.ref, update })), ) } @@ -140,35 +132,41 @@ export class LinearIssue extends Schema.TaggedClass()('LinearIssue' return this.update({ delegateId }) } addReaction(emoji: string): Effect.Effect { - return Effect.flatMap(LinearApiHandle, (api) => + return Effect.flatMap(LinearApi, (api) => api.createReaction( LinearCreateReactionRequest.make({ target: LinearReactionTarget.cases.Issue.make({ issue: this.ref }), emoji, }), ), - ) + ).pipe(Effect.map((record) => LinearReaction.make(record))) } createAttachment( input: LinearCreateAttachmentInput, ): Effect.Effect { - return Effect.flatMap(LinearApiHandle, (api) => + return Effect.flatMap(LinearApi, (api) => api.createAttachment(LinearCreateAttachmentRequest.make({ issue: this.ref, input })), - ) + ).pipe(Effect.map((record) => LinearIssueAttachment.make(record))) } /** Uploads bytes as a Linear asset. Place the returned `url` in issue or comment Markdown to show it. */ uploadFile(input: LinearUploadFileInput): Effect.Effect { - return Effect.flatMap(LinearApiHandle, (api) => + return Effect.flatMap(LinearApi, (api) => api.uploadFile(LinearUploadFileRequest.make({ issue: this.ref, input })), - ).pipe(Effect.withSpan('linear.issue.upload_file', { attributes: issueAttributes(this.ref.issueId) })) + ).pipe( + Effect.map((record) => LinearFile.make(record)), + Effect.withSpan('linear.issue.upload_file', { attributes: issueAttributes(this.ref.issueId) }), + ) } /** Uploads bytes and attaches the asset to this issue as a first-class Linear attachment card. */ uploadAttachment( input: LinearUploadAttachmentInput, ): Effect.Effect { - return Effect.flatMap(LinearApiHandle, (api) => + return Effect.flatMap(LinearApi, (api) => api.uploadAttachment(LinearUploadAttachmentRequest.make({ issue: this.ref, input })), - ).pipe(Effect.withSpan('linear.issue.upload_attachment', { attributes: issueAttributes(this.ref.issueId) })) + ).pipe( + Effect.map((record) => LinearIssueAttachment.make(record)), + Effect.withSpan('linear.issue.upload_attachment', { attributes: issueAttributes(this.ref.issueId) }), + ) } } @@ -176,41 +174,46 @@ export class LinearComment extends Schema.TaggedClass()('LinearCo ...LinearCommentSnapshot.fields, files: Schema.Array(LinearFile), }) { + /** Wraps a comment record from `LinearApi`, and each of its files. */ + static fromRecord(record: LinearCommentRecord): LinearComment { + return LinearComment.make({ ...record, files: record.files.map((file) => LinearFile.make(file)) }) + } + reply(content: LinearContent): Effect.Effect { - return Effect.flatMap(LinearApiHandle, (api) => + return Effect.flatMap(LinearApi, (api) => api.createComment( LinearCreateCommentRequest.make({ issue: this.issue, parentId: this.ref.commentId, content }), ), - ) + ).pipe(Effect.map((record) => LinearComment.fromRecord(record))) } update(content: LinearContent): Effect.Effect { - return Effect.flatMap(LinearApiHandle, (api) => + return Effect.flatMap(LinearApi, (api) => api.updateComment(LinearUpdateCommentRequest.make({ comment: this.ref, content })), - ) + ).pipe(Effect.map((record) => LinearComment.fromRecord(record))) } remove(): Effect.Effect { - return Effect.flatMap(LinearApiHandle, (api) => + return Effect.flatMap(LinearApi, (api) => api.deleteComment(LinearDeleteCommentRequest.make({ comment: this.ref })), ) } addReaction(emoji: string): Effect.Effect { - return Effect.flatMap(LinearApiHandle, (api) => + return Effect.flatMap(LinearApi, (api) => api.createReaction( LinearCreateReactionRequest.make({ target: LinearReactionTarget.cases.Comment.make({ comment: this.ref }), emoji, }), ), - ) + ).pipe(Effect.map((record) => LinearReaction.make(record))) } } -export class LinearReaction extends Schema.TaggedClass()('LinearReaction', { - ...LinearReactionSnapshot.fields, - ref: Schema.Struct({ issue: LinearIssueRef, reactionId: LinearReactionId }), -}) { +export class LinearReaction extends Schema.TaggedClass()( + 'LinearReaction', + LinearReactionRecord.fields, +) { remove(): Effect.Effect { - return Effect.flatMap(LinearApiHandle, (api) => + return Effect.flatMap(LinearApi, (api) => api.deleteReaction( LinearDeleteReactionRequest.make({ issue: this.ref.issue, reactionId: this.ref.reactionId }), ), @@ -218,17 +221,17 @@ export class LinearReaction extends Schema.TaggedClass()('Linear } } -export class LinearIssueAttachment extends Schema.TaggedClass()('LinearIssueAttachment', { - ...LinearIssueAttachmentSnapshot.fields, - ref: LinearAttachmentRef, -}) { +export class LinearIssueAttachment extends Schema.TaggedClass()( + 'LinearIssueAttachment', + LinearIssueAttachmentRecord.fields, +) { update(input: LinearUpdateAttachmentInput): Effect.Effect { - return Effect.flatMap(LinearApiHandle, (api) => + return Effect.flatMap(LinearApi, (api) => api.updateAttachment(LinearUpdateAttachmentRequest.make({ attachment: this.ref, input })), - ) + ).pipe(Effect.map((record) => LinearIssueAttachment.make(record))) } remove(): Effect.Effect { - return Effect.flatMap(LinearApiHandle, (api) => + return Effect.flatMap(LinearApi, (api) => api.deleteAttachment(LinearDeleteAttachmentRequest.make({ attachment: this.ref })), ) } @@ -261,7 +264,7 @@ export class LinearAgentSession extends Schema.TaggedClass() ephemeral, deliveryId: this.deliveryId, } - return Effect.flatMap(LinearApiHandle, (api) => + return Effect.flatMap(LinearApi, (api) => api.createAgentActivity( LinearCreateAgentActivityRequest.make( Predicate.isUndefined(activityId) ? request : { ...request, activityId }, diff --git a/packages/linear/src/api/CreateReaction.ts b/packages/linear/src/api/CreateReaction.ts index 3ade759..a593c7b 100644 --- a/packages/linear/src/api/CreateReaction.ts +++ b/packages/linear/src/api/CreateReaction.ts @@ -2,7 +2,7 @@ import { Effect, Match, Schema } from 'effect' import { LinearCreateReactionRequest } from '../LinearApiContract' import { LinearCommentId, LinearIssueId, LinearReactionId } from '../LinearIdentity' -import { LinearReaction } from '../LinearResources' +import { LinearReactionRecord } from '../LinearResourceRecords' import { failLinearMutation } from './LinearApiErrors' import { projectLinearParticipant } from './LinearApiProjections' import { LinearApiReaction } from './LinearApiSchemas' @@ -46,7 +46,7 @@ export const createReaction = Effect.fn('linear.api.create_reaction')((input: Li Effect.flatMap(({ reactionCreate }) => { if (!reactionCreate.success) return failLinearMutation('create_reaction') return Effect.succeed( - LinearReaction.make({ + LinearReactionRecord.make({ id: reactionCreate.reaction.id, issueId: target.issue.issueId, commentId: target.commentId, diff --git a/packages/linear/src/api/DownloadLinearFile.ts b/packages/linear/src/api/DownloadLinearFile.ts index e384d60..ddb8b7d 100644 --- a/packages/linear/src/api/DownloadLinearFile.ts +++ b/packages/linear/src/api/DownloadLinearFile.ts @@ -9,7 +9,7 @@ import { type LinearDownloadFileRequest, LinearFileSizeLimitExceeded, type LinearFileTransferPolicy, -} from '../LinearFiles' +} from '../LinearFileModels' import { LinearFileOriginRejectedError, LinearFileRedirectError, diff --git a/packages/linear/src/api/LinearApiProjections.ts b/packages/linear/src/api/LinearApiProjections.ts index 3d280c6..1ad3029 100644 --- a/packages/linear/src/api/LinearApiProjections.ts +++ b/packages/linear/src/api/LinearApiProjections.ts @@ -1,5 +1,5 @@ import type { LinearIssueRequest } from '../LinearApiContract' -import { discoverLinearFiles, LinearFile, LinearFileRef } from '../LinearFiles' +import { discoverLinearFileRecords, LinearFileRecord, LinearFileRef } from '../LinearFileModels' import { LinearCommentRef, LinearIssueInfo, @@ -8,7 +8,7 @@ import { LinearUser, LinearWorkflowState, } from '../LinearModels' -import { LinearComment, LinearIssueAttachment } from '../LinearResources' +import { LinearCommentRecord, LinearIssueAttachmentRecord } from '../LinearResourceRecords' import { LinearApiAttachment, LinearApiComment, LinearApiIssue, LinearApiParticipant } from './LinearApiSchemas' import type { LinearUploadTarget } from './RequestLinearFileUpload' @@ -38,13 +38,13 @@ export const projectLinearIssue = (ref: LinearIssueRequest['issue'], value: type }) export const projectLinearComment = (issue: LinearIssueRequest['issue'], value: typeof LinearApiComment.Type) => - LinearComment.make({ + LinearCommentRecord.make({ ref: LinearCommentRef.make({ ...issue, commentId: value.id }), issue, parentCommentId: value.parent?.id ?? null, content: { markdown: value.body }, author: projectLinearParticipant(value.user), - files: discoverLinearFiles( + files: discoverLinearFileRecords( LinearFileRef.make({ organizationId: issue.organizationId, issueId: issue.issueId }), value.body, ), @@ -56,7 +56,7 @@ export const projectLinearUploadedFile = ( filename: string, target: LinearUploadTarget, ) => - LinearFile.make({ + LinearFileRecord.make({ ref: LinearFileRef.make({ organizationId: issue.organizationId, issueId: issue.issueId }), url: target.assetUrl, name: filename, @@ -65,7 +65,7 @@ export const projectLinearUploadedFile = ( }) export const projectLinearAttachment = (issue: LinearIssueRequest['issue'], value: typeof LinearApiAttachment.Type) => - LinearIssueAttachment.make({ ...value, issueId: issue.issueId, ref: { issue, attachmentId: value.id } }) + LinearIssueAttachmentRecord.make({ ...value, issueId: issue.issueId, ref: { issue, attachmentId: value.id } }) export const projectLinearUser = (value: typeof LinearApiParticipant.Type) => LinearUser.make({ diff --git a/packages/linear/src/api/LinearHttpClient.ts b/packages/linear/src/api/LinearHttpClient.ts index 9545f69..69b0734 100644 --- a/packages/linear/src/api/LinearHttpClient.ts +++ b/packages/linear/src/api/LinearHttpClient.ts @@ -7,7 +7,7 @@ import type { HttpClientResponse } from 'effect/http/HttpClientResponse' import type { LinearApiError, LinearApiOperation } from '../LinearApiContract' import { LinearCredentialResolver } from '../LinearCredential' -import { isLinearFileOrigin } from '../LinearFiles' +import { isLinearFileOrigin } from '../LinearFileModels' import type { LinearOrganizationId } from '../LinearIdentity' import { LinearFileOriginRejectedError, LinearTransportError, linearProviderErrorDetails } from './LinearApiErrors' diff --git a/packages/linear/src/api/ListIssueAttachments.ts b/packages/linear/src/api/ListIssueAttachments.ts index 6b00f67..f833788 100644 --- a/packages/linear/src/api/ListIssueAttachments.ts +++ b/packages/linear/src/api/ListIssueAttachments.ts @@ -2,7 +2,7 @@ import { Effect, Schema } from 'effect' import type { LinearApiError, LinearIssueRequest } from '../LinearApiContract' import { LinearIssueId } from '../LinearIdentity' -import type { LinearIssueAttachment } from '../LinearResources' +import type { LinearIssueAttachmentRecord } from '../LinearResourceRecords' import { type LinearProviderError, LinearResponseDecodeError, linearProviderErrorDetails } from './LinearApiErrors' import { projectLinearAttachment } from './LinearApiProjections' import { LinearApiAttachment, LinearApiPageInfo } from './LinearApiSchemas' @@ -23,8 +23,12 @@ const ListIssueAttachmentsResponse = Schema.Struct({ export const listIssueAttachments = Effect.fn('linear.api.list_issue_attachments')((input: LinearIssueRequest) => { const page = ( variables: typeof ListIssueAttachmentsVariables.Type, - accumulated: ReadonlyArray, - ): Effect.Effect, LinearProviderError | LinearApiError, LinearHttpClient> => + accumulated: ReadonlyArray, + ): Effect.Effect< + ReadonlyArray, + LinearProviderError | LinearApiError, + LinearHttpClient + > => linearGraphql({ operation: 'list_issue_attachments', query: document, diff --git a/packages/linear/src/api/ListIssueComments.ts b/packages/linear/src/api/ListIssueComments.ts index 3c8c397..a2707bd 100644 --- a/packages/linear/src/api/ListIssueComments.ts +++ b/packages/linear/src/api/ListIssueComments.ts @@ -2,7 +2,7 @@ import { Effect, Schema } from 'effect' import type { LinearApiError, LinearIssueRequest } from '../LinearApiContract' import { LinearIssueId } from '../LinearIdentity' -import type { LinearComment } from '../LinearResources' +import type { LinearCommentRecord } from '../LinearResourceRecords' import { type LinearProviderError, LinearResponseDecodeError, linearProviderErrorDetails } from './LinearApiErrors' import { projectLinearComment } from './LinearApiProjections' import { LinearApiComment, LinearApiPageInfo } from './LinearApiSchemas' @@ -20,8 +20,8 @@ const ListIssueCommentsResponse = Schema.Struct({ export const listIssueComments = Effect.fn('linear.api.list_issue_comments')((input: LinearIssueRequest) => { const page = ( variables: typeof ListIssueCommentsVariables.Type, - accumulated: ReadonlyArray, - ): Effect.Effect, LinearProviderError | LinearApiError, LinearHttpClient> => + accumulated: ReadonlyArray, + ): Effect.Effect, LinearProviderError | LinearApiError, LinearHttpClient> => linearGraphql({ operation: 'list_issue_comments', query: document, diff --git a/packages/linear/src/api/RequestLinearFileUpload.ts b/packages/linear/src/api/RequestLinearFileUpload.ts index d8ca0aa..ac00e28 100644 --- a/packages/linear/src/api/RequestLinearFileUpload.ts +++ b/packages/linear/src/api/RequestLinearFileUpload.ts @@ -1,6 +1,6 @@ import { Effect, Schema } from 'effect' -import { LinearFileUrl, type LinearUploadFileRequest } from '../LinearFiles' +import { LinearFileUrl, type LinearUploadFileRequest } from '../LinearFileModels' import { failLinearMutation } from './LinearApiErrors' import { linearGraphql } from './LinearGraphql' diff --git a/packages/linear/src/index.ts b/packages/linear/src/index.ts index 3d7caa2..c915b9e 100644 --- a/packages/linear/src/index.ts +++ b/packages/linear/src/index.ts @@ -13,11 +13,13 @@ export * from './LinearCallbacks' export * from './LinearDeliveryDestination' export * from './LinearDeliveryOutput' export * from './LinearEventProcessor' +export * from './LinearFileModels' export * from './LinearFiles' export * from './LinearIdentity' export * from './LinearIssueChanges' export * from './LinearLifecycle' export * from './LinearModels' +export * from './LinearResourceRecords' export * from './LinearResources' export * from './LinearWebhookEventSchemas' export * from './LinearWebhookProvider' diff --git a/packages/linear/test/linear-delivery-output.vi.test.ts b/packages/linear/test/linear-delivery-output.vi.test.ts index ecdfcb5..819aa1b 100644 --- a/packages/linear/test/linear-delivery-output.vi.test.ts +++ b/packages/linear/test/linear-delivery-output.vi.test.ts @@ -64,7 +64,7 @@ import { LinearReactionId, LinearUserId, } from '../src/LinearIdentity' -import { LinearComment, LinearReaction } from '../src/LinearResources' +import { LinearCommentRecord, LinearReactionRecord } from '../src/LinearResourceRecords' import { linearAppUserId, linearOrganizationId } from './fixtures' const idempotencyKey = '5b0c3a1e-8d2f-4c61-9a57-1f2e3d4c5b6a' @@ -180,7 +180,7 @@ const run = ( record('reactions', request).pipe( Effect.andThen(reactionAnswer), Effect.as( - LinearReaction.make({ + LinearReactionRecord.make({ id: request.reactionId ?? LinearReactionId.make('assigned-by-linear'), issueId, commentId: null, @@ -224,7 +224,7 @@ const run = ( record('comments', request).pipe( Effect.andThen(commentAnswer), Effect.as( - LinearComment.make({ + LinearCommentRecord.make({ ref: commentRef, issue: LinearIssueRef.make({ organizationId: linearOrganizationId, teamId: null, issueId }), parentCommentId: null, @@ -238,7 +238,7 @@ const run = ( record('commentUpdates', request).pipe( Effect.andThen(commentAnswer), Effect.as( - LinearComment.make({ + LinearCommentRecord.make({ ref: request.comment, issue: LinearIssueRef.make({ organizationId: linearOrganizationId, teamId: null, issueId }), parentCommentId: null, diff --git a/packages/linear/test/linear-file-download.vi.test.ts b/packages/linear/test/linear-file-download.vi.test.ts index 2e8b1f0..b4630ef 100644 --- a/packages/linear/test/linear-file-download.vi.test.ts +++ b/packages/linear/test/linear-file-download.vi.test.ts @@ -5,7 +5,8 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from 'effect/http' import { narrowLinearProviderErrors } from '../src/api/LinearApiErrors' import { LinearHttpClient } from '../src/api/LinearHttpClient' import { LinearApi } from '../src/LinearApi' -import { LinearFile, LinearFileRef, LinearFileSizeLimitExceeded, LinearFileUrl } from '../src/LinearFiles' +import { LinearFileRef, LinearFileSizeLimitExceeded, LinearFileUrl } from '../src/LinearFileModels' +import { LinearFile } from '../src/LinearFiles' import { LinearOrganizationId } from '../src/LinearIdentity' import { apiLayer, issue, viewer } from './api-test-fixtures' diff --git a/packages/linear/test/linear-file-upload.vi.test.ts b/packages/linear/test/linear-file-upload.vi.test.ts index 0bc7c88..9ee02c3 100644 --- a/packages/linear/test/linear-file-upload.vi.test.ts +++ b/packages/linear/test/linear-file-upload.vi.test.ts @@ -6,8 +6,8 @@ import { narrowLinearProviderErrors } from '../src/api/LinearApiErrors' import { LinearHttpClient } from '../src/api/LinearHttpClient' import { requestLinearFileUpload } from '../src/api/RequestLinearFileUpload' import { LinearApi } from '../src/LinearApi' -import { LinearFile } from '../src/LinearFiles' -import { LinearIssueAttachment } from '../src/LinearResources' +import { LinearFileRecord } from '../src/LinearFileModels' +import { LinearIssueAttachmentRecord } from '../src/LinearResourceRecords' import { apiLayer, issue, viewer } from './api-test-fixtures' const assetUrl = @@ -151,7 +151,7 @@ describe('Linear file upload', () => { expect(recorded.slice(0, 2).every((request) => request.authorization === 'Bearer token-never-log')).toBe( true, ) - expect(file).toBeInstanceOf(LinearFile) + expect(Schema.is(LinearFileRecord)(file)).toBe(true) expect(file).toMatchObject({ ref: { organizationId: issue.organizationId, issueId: issue.issueId }, url: assetUrl, @@ -185,7 +185,7 @@ describe('Linear file upload', () => { metadata: { contentType: 'text/plain', size: bytes.byteLength, source: 'test' }, }, }) - expect(attachment).toBeInstanceOf(LinearIssueAttachment) + expect(Schema.is(LinearIssueAttachmentRecord)(attachment)).toBe(true) expect(attachment.url).toBe(assetUrl) }), ) diff --git a/packages/linear/test/linear-files.vi.test.ts b/packages/linear/test/linear-files.vi.test.ts index 96ac182..2ec82f2 100644 --- a/packages/linear/test/linear-files.vi.test.ts +++ b/packages/linear/test/linear-files.vi.test.ts @@ -6,14 +6,8 @@ import { LinearApi } from '../src/LinearApi' import type { LinearIssueCreated, LinearSubscribedEvents } from '../src/LinearCallbackEvents' import type { LinearCallbackHandlers } from '../src/LinearCallbacks' import { makeLinearEventProcessor } from '../src/LinearEventProcessor' -import { - canonicalLinearFileUrl, - discoverLinearFiles, - LinearFile, - LinearFileRef, - LinearFileUrl, - LinearUploadFileInput, -} from '../src/LinearFiles' +import { canonicalLinearFileUrl, LinearFileRef, LinearFileUrl, LinearUploadFileInput } from '../src/LinearFileModels' +import { discoverLinearFiles, LinearFile } from '../src/LinearFiles' import { LinearAttachmentId } from '../src/LinearIdentity' import { LinearIssueAttachment, LinearIssue } from '../src/LinearResources' import { issue } from './api-test-fixtures' diff --git a/packages/linear/test/linear-remote-delivery.vi.test.ts b/packages/linear/test/linear-remote-delivery.vi.test.ts index 44bb39c..66dd96f 100644 --- a/packages/linear/test/linear-remote-delivery.vi.test.ts +++ b/packages/linear/test/linear-remote-delivery.vi.test.ts @@ -28,7 +28,7 @@ import { LinearIssueRef, type LinearCreateAgentActivityRequest, } from '../src/LinearModels' -import { LinearComment } from '../src/LinearResources' +import { LinearCommentRecord } from '../src/LinearResourceRecords' import { agentSessionPayloads, issueCreatePayload, @@ -72,7 +72,7 @@ const makeFakeLinear = (options: FakeLinearOptions = {}) => const answersLost = yield* Ref.make(0) const commentCount = yield* Ref.make(0) const commentOn = (issueId: LinearIssueId, commentId: string, markdown: string) => - LinearComment.make({ + LinearCommentRecord.make({ ref: LinearCommentRef.make({ organizationId: linearOrganizationId, teamId: null, diff --git a/packages/linear/test/linear-resource-methods.vi.test.ts b/packages/linear/test/linear-resource-methods.vi.test.ts index 0da21c6..3c3c0a5 100644 --- a/packages/linear/test/linear-resource-methods.vi.test.ts +++ b/packages/linear/test/linear-resource-methods.vi.test.ts @@ -11,6 +11,7 @@ import { LinearWorkflowStateId, } from '../src/LinearIdentity' import { LinearCommentRef, LinearContent } from '../src/LinearModels' +import { LinearCommentRecord, LinearIssueAttachmentRecord, LinearReactionRecord } from '../src/LinearResourceRecords' import { LinearComment, LinearIssue, LinearIssueAttachment, LinearReaction } from '../src/LinearResources' import { issue } from './api-test-fixtures' @@ -31,7 +32,7 @@ describe('Linear resource methods', () => { delegate: null, } const commentRef = LinearCommentRef.make({ ...issue, commentId: LinearCommentId.make('comment-1') }) - const comment = LinearComment.make({ + const commentRecord = LinearCommentRecord.make({ ref: commentRef, issue, parentCommentId: null, @@ -39,7 +40,7 @@ describe('Linear resource methods', () => { author: null, files: [], }) - const reaction = LinearReaction.make({ + const reactionRecord = LinearReactionRecord.make({ id: LinearReactionId.make('reaction-1'), issueId: issue.issueId, commentId: null, @@ -47,7 +48,7 @@ describe('Linear resource methods', () => { author: null, ref: { issue, reactionId: LinearReactionId.make('reaction-1') }, }) - const attachment = LinearIssueAttachment.make({ + const attachmentRecord = LinearIssueAttachmentRecord.make({ id: LinearAttachmentId.make('attachment-1'), issueId: issue.issueId, title: 'Card', @@ -55,6 +56,9 @@ describe('Linear resource methods', () => { url: 'https://example.com', ref: { issue, attachmentId: LinearAttachmentId.make('attachment-1') }, }) + const comment = LinearComment.fromRecord(commentRecord) + const reaction = LinearReaction.make(reactionRecord) + const attachment = LinearIssueAttachment.make(attachmentRecord) const record = (method: string, output: A) => (input: Input) => @@ -77,15 +81,15 @@ describe('Linear resource methods', () => { canAccessAnyPublicTeam: false, teamIds: [], }), - listIssueComments: record('listIssueComments', [comment]), - listIssueAttachments: record('listIssueAttachments', [attachment]), - createComment: record('createComment', comment), - updateComment: record('updateComment', comment), + listIssueComments: record('listIssueComments', [commentRecord]), + listIssueAttachments: record('listIssueAttachments', [attachmentRecord]), + createComment: record('createComment', commentRecord), + updateComment: record('updateComment', commentRecord), deleteComment: record('deleteComment', undefined), - createReaction: record('createReaction', reaction), + createReaction: record('createReaction', reactionRecord), deleteReaction: record('deleteReaction', undefined), - createAttachment: record('createAttachment', attachment), - updateAttachment: record('updateAttachment', attachment), + createAttachment: record('createAttachment', attachmentRecord), + updateAttachment: record('updateAttachment', attachmentRecord), deleteAttachment: record('deleteAttachment', undefined), }) const resource = LinearIssue.make({ @@ -106,23 +110,29 @@ describe('Linear resource methods', () => { yield* run(resource.listAssignableUsers({ first: 10, query: 'u' })) yield* run(resource.listAppUsers({ after: 'cursor' })) yield* run(resource.getUser(LinearUserId.make('user-1'))) - yield* run(resource.listComments()) - yield* run(resource.listAttachments()) - yield* run(resource.postComment({ markdown: 'new' })) + const listed = yield* run(resource.listComments()) + expect(listed[0]).toBeInstanceOf(LinearComment) + const attachments = yield* run(resource.listAttachments()) + expect(attachments[0]).toBeInstanceOf(LinearIssueAttachment) + expect(yield* run(resource.postComment({ markdown: 'new' }))).toBeInstanceOf(LinearComment) yield* run(resource.setStatus(LinearWorkflowStateId.make('done'))) yield* run(resource.setPriority(2)) yield* run(resource.addLabels([LinearIssueLabelId.make('label')])) yield* run(resource.removeLabels([LinearIssueLabelId.make('label')])) yield* run(resource.assignTo(LinearUserId.make('human'))) yield* run(resource.delegateTo(LinearUserId.make('app'))) - yield* run(resource.addReaction('eyes')) - yield* run(resource.createAttachment({ url: 'https://example.com', title: 'Card' })) - yield* run(comment.reply({ markdown: 'reply' })) - yield* run(comment.update({ markdown: 'edit' })) + expect(yield* run(resource.addReaction('eyes'))).toBeInstanceOf(LinearReaction) + expect(yield* run(resource.createAttachment({ url: 'https://example.com', title: 'Card' }))).toBeInstanceOf( + LinearIssueAttachment, + ) + expect(yield* run(comment.reply({ markdown: 'reply' }))).toBeInstanceOf(LinearComment) + expect(yield* run(comment.update({ markdown: 'edit' }))).toBeInstanceOf(LinearComment) yield* run(comment.remove()) yield* run(comment.addReaction('thumbsup')) yield* run(reaction.remove()) - yield* run(attachment.update({ title: 'Renamed', subtitle: null })) + expect(yield* run(attachment.update({ title: 'Renamed', subtitle: null }))).toBeInstanceOf( + LinearIssueAttachment, + ) yield* run(attachment.remove()) const recorded = Array.from(yield* Queue.takeAll(calls)) diff --git a/packages/slack/README.md b/packages/slack/README.md index a435ecd..539b388 100644 --- a/packages/slack/README.md +++ b/packages/slack/README.md @@ -75,7 +75,7 @@ Add `slack` to the providers passed to `Channels.make` or a host-specific wrappe ## Files -Messages carry `files: ReadonlyArray`, whether they come from a webhook or from `SlackApi` reads. Each `SlackFile` has a stable `ref` (`teamId` and `fileId`) plus a nullable `name`, `contentType`, `size`, and `downloadUrl`. Slack's `url_private_download` wins over `url_private`. +Messages carry `files: ReadonlyArray`, whether they come from a webhook or from `SlackThread` reads such as `listMessages()`. `SlackApi` itself returns plain records (`SlackMessageRecord`, `SlackFileRecord`) with the same fields and no methods; `slackMessageFromRecord` and `slackFileFromRecord` wrap them. Each `SlackFile` has a stable `ref` (`teamId` and `fileId`) plus a nullable `name`, `contentType`, `size`, and `downloadUrl`. Slack's `url_private_download` wins over `url_private`. ```ts onSubscribedThreadEvents: (event) => diff --git a/packages/slack/scripts/slack-api-live-smoke.ts b/packages/slack/scripts/slack-api-live-smoke.ts index 09e69a7..49fa329 100644 --- a/packages/slack/scripts/slack-api-live-smoke.ts +++ b/packages/slack/scripts/slack-api-live-smoke.ts @@ -5,10 +5,10 @@ import { Hex } from 'effect/encoding' import { SlackApi } from '../src/SlackApi' import { SlackApiLive } from '../src/SlackApiLive' import { SlackChannel } from '../src/SlackChannel' +import { SlackFile } from '../src/SlackFile' import { SlackChannelId, SlackMessageTs, SlackTeamId } from '../src/SlackIdentity' import { SlackChannelRef, - type SlackFile, SlackMarkdownContent, SlackPlainTextContent, SlackReaction, @@ -105,7 +105,7 @@ const verifyKnownThreadFile = Effect.fn('slack.api.live_smoke.verify_known_threa if (known === undefined) { return yield* new SlackLiveSmokeError({ message: 'The smoke-test thread does not contain a file' }) } - const downloaded = yield* downloadedSha256(known) + const downloaded = yield* downloadedSha256(SlackFile.make(known)) if (downloaded.sha256 !== expectedSha256.toLowerCase()) { return yield* new SlackLiveSmokeError({ message: `Known file ${known.ref.fileId} has a different SHA-256` }) } diff --git a/packages/slack/src/SlackApi.ts b/packages/slack/src/SlackApi.ts index 6120b9b..03ac43b 100644 --- a/packages/slack/src/SlackApi.ts +++ b/packages/slack/src/SlackApi.ts @@ -17,7 +17,6 @@ import { type SlackMessageRequest, type SlackParticipantRequest, } from './SlackApiContract' -import { slackApiKey } from './SlackApiHandle' import { chronological, contentText, @@ -48,12 +47,12 @@ import { SlackChannelInfo, SlackChannelRef, type SlackContent, - slackFileFromMetadata, + slackFileRecordFromMetadata, SlackMarkdownContent, - SlackMessage, + SlackMessageRecord, SlackMessageRef, SlackParticipant, - SlackSentMessage, + SlackSentMessageRecord, SlackThreadInfo, SlackThreadRef, type SlackUploadFileInput, @@ -64,11 +63,12 @@ import { SlackFileMetadata } from './SlackWebhookEventSchemas' export * from './SlackApiContract' /** - * Application-wide Slack operations. Behavior-bearing Slack values pass their stored refs here. + * Application-wide Slack operations on plain records. Callback objects such as `SlackThread` call it and wrap + * the records it returns. * * `layer` needs `SlackHttpClient` and `HttpClient`; `SlackApiLive` provides both. */ -export class SlackApi extends Context.Service()(slackApiKey, { +export class SlackApi extends Context.Service()('@humanlayer/channels-slack/SlackApi', { /** Reads the optional `SLACK_BOT_USER_ID`; without it the first call that needs the bot's user ID asks `auth.test`. */ make: Effect.gen(function* () { const client = yield* SlackHttpClient @@ -216,7 +216,7 @@ export class SlackApi extends Context.Service()(sl isMe: false, }), ) - return SlackMessage.make({ + return SlackMessageRecord.make({ ref: SlackMessageRef.make({ teamId: thread.teamId, channelId: thread.channelId, @@ -225,7 +225,7 @@ export class SlackApi extends Context.Service()(sl thread, author, content: SlackMarkdownContent.make({ markdown: snapshot.text ?? '' }), - files: (snapshot.files ?? []).map((file) => slackFileFromMetadata(thread.teamId, file)), + files: (snapshot.files ?? []).map((file) => slackFileRecordFromMetadata(thread.teamId, file)), metadata: {}, ...Struct.renameKeys(Struct.pick(snapshot, ['user_team']), { user_team: 'authorTeamId' }), }) @@ -259,7 +259,7 @@ export class SlackApi extends Context.Service()(sl Effect.provideService(HttpClient.HttpClient, http), ) const metadata = yield* requireFilesWrite(completeSlackFileUpload({ target, destination, input })) - const file = slackFileFromMetadata( + const file = slackFileRecordFromMetadata( destination.channel.teamId, SlackFileMetadata.make({ ...metadata, name: input.filename, size: input.bytes.byteLength }), ) @@ -380,7 +380,7 @@ export class SlackApi extends Context.Service()(sl ), ) } - return SlackSentMessage.make({ ref: message.ref, message }) + return SlackSentMessageRecord.make({ ref: message.ref, message }) }) const service: SlackApiOperations = { @@ -579,7 +579,7 @@ export class SlackApi extends Context.Service()(sl const ownUserId = yield* authUserId const snapshot = stopped.message ?? SlackMessageSnapshot.make({ ts: stopped.ts, user: ownUserId }) const message = yield* normalizeMessage(thread, snapshot) - return SlackSentMessage.make({ ref: message.ref, message }) + return SlackSentMessageRecord.make({ ref: message.ref, message }) }), postPlanToThread: ({ thread, plan }) => callSlack( diff --git a/packages/slack/src/SlackApiContract.ts b/packages/slack/src/SlackApiContract.ts index 9975d55..dd75855 100644 --- a/packages/slack/src/SlackApiContract.ts +++ b/packages/slack/src/SlackApiContract.ts @@ -8,12 +8,12 @@ import { Schema } from 'effect' import { SlackMessageTs, SlackTeamId } from './SlackIdentity' import type { SlackChannelInfo, - SlackFile, - SlackMessage, - SlackMessages, + SlackFileRecord, + SlackMessageRecord, + SlackMessageRecords, SlackParticipant, SlackParticipants, - SlackSentMessage, + SlackSentMessageRecord, SlackThreadInfo, } from './SlackModels' import { @@ -190,15 +190,18 @@ export const SlackFileDownloadError = Schema.Union([ ]) export type SlackFileDownloadError = typeof SlackFileDownloadError.Type -/** Application-wide Slack operations. Behavior-bearing Slack values pass their stored refs here. */ +/** + * Application-wide Slack operations. They take and return plain records; `SlackThread`, `SlackChannel`, + * `SlackMessage`, and `SlackFile` call them with their stored refs and wrap the records they get back. + */ export type SlackApiOperations = { readonly listParticipants: (request: SlackThreadRequest) => Effect.Effect - readonly listThreadMessages: (request: SlackThreadRequest) => Effect.Effect + readonly listThreadMessages: (request: SlackThreadRequest) => Effect.Effect readonly listChannelMessagesBeforeThread: ( request: SlackListChannelMessagesBeforeThreadRequest, - ) => Effect.Effect - readonly postToThread: (request: SlackPostToThreadRequest) => Effect.Effect - readonly postToChannel: (request: SlackPostToChannelRequest) => Effect.Effect + ) => Effect.Effect + readonly postToThread: (request: SlackPostToThreadRequest) => Effect.Effect + readonly postToChannel: (request: SlackPostToChannelRequest) => Effect.Effect /** `chat.update`: replace the text of a message the bot posted. */ readonly updateMessage: (request: SlackUpdateMessageRequest) => Effect.Effect /** `chat.delete`: remove a message the bot posted. */ @@ -211,7 +214,7 @@ export type SlackApiOperations = { readonly stream: ( thread: SlackThreadRef, chunks: Stream.Stream, - ) => Effect.Effect + ) => Effect.Effect /** `chat.postMessage` with one plan block: post a task list to a thread. */ readonly postPlanToThread: (request: SlackPostPlanRequest) => Effect.Effect /** `chat.update` with one plan block: replace the whole task list a plan message shows. */ @@ -219,7 +222,7 @@ export type SlackApiOperations = { readonly addReaction: (request: SlackReactionRequest) => Effect.Effect readonly removeReaction: (request: SlackReactionRequest) => Effect.Effect readonly resolveParticipant: (request: SlackParticipantRequest) => Effect.Effect - readonly getMessage: (request: SlackMessageRequest) => Effect.Effect + readonly getMessage: (request: SlackMessageRequest) => Effect.Effect readonly resolveReactionThread: ( request: SlackResolveReactionThreadRequest, ) => Effect.Effect @@ -227,10 +230,10 @@ export type SlackApiOperations = { readonly getChannelInfo: (request: SlackChannelRequest) => Effect.Effect readonly uploadFileToChannel: ( request: SlackUploadFileToChannelRequest, - ) => Effect.Effect + ) => Effect.Effect readonly uploadFileToThread: ( request: SlackUploadFileToThreadRequest, - ) => Effect.Effect + ) => Effect.Effect readonly downloadFile: ( request: SlackDownloadFileRequest, ) => Effect.Effect, SlackFileDownloadError> diff --git a/packages/slack/src/SlackApiHandle.ts b/packages/slack/src/SlackApiHandle.ts deleted file mode 100644 index 8d958b9..0000000 --- a/packages/slack/src/SlackApiHandle.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * A second handle on the `SlackApi` service. It shares the service's key, so it reads the same slot in - * context. Slack files and messages read the service through it: importing `SlackApi` itself would form an - * import cycle, since `SlackApi`'s constructor builds those files and messages. - */ -import { Context } from 'effect' - -import type { SlackApi } from './SlackApi' -import type { SlackApiOperations } from './SlackApiContract' - -export const slackApiKey = '@humanlayer/channels-slack/SlackApi' - -export const SlackApiHandle = Context.Service(slackApiKey) diff --git a/packages/slack/src/SlackApiWire.ts b/packages/slack/src/SlackApiWire.ts index cb99a20..8df3f9a 100644 --- a/packages/slack/src/SlackApiWire.ts +++ b/packages/slack/src/SlackApiWire.ts @@ -6,7 +6,7 @@ import { Effect, Match, Option, Predicate, Schema } from 'effect' import { isSlackTokenRejected, type SlackApiError } from './SlackApiContract' import { SlackMessageTs, SlackTeamId } from './SlackIdentity' -import type { SlackChannelRef, SlackContent, SlackMessages, SlackPlan, SlackUserId } from './SlackModels' +import type { SlackChannelRef, SlackContent, SlackMessageRecords, SlackPlan, SlackUserId } from './SlackModels' import type { SlackStreamChunk } from './SlackStreamChunk' import { SlackFileMetadata } from './SlackWebhookEventSchemas' @@ -243,5 +243,5 @@ export const nextCursor = (metadata: typeof SlackResponseMetadata.Type | undefin return Predicate.isUndefined(cursor) || cursor.length === 0 ? Option.none() : Option.some(cursor) } -export const chronological = (messages: SlackMessages): SlackMessages => +export const chronological = (messages: SlackMessageRecords): SlackMessageRecords => Array.from(messages).sort((left, right) => left.ref.messageTs.localeCompare(right.ref.messageTs)) diff --git a/packages/slack/src/SlackCallbackEvents.ts b/packages/slack/src/SlackCallbackEvents.ts index eef7bc5..2b5a1bf 100644 --- a/packages/slack/src/SlackCallbackEvents.ts +++ b/packages/slack/src/SlackCallbackEvents.ts @@ -1,6 +1,7 @@ import { Schema } from 'effect' -import { SlackMessage, SlackMessageRef, SlackParticipant, SlackReaction } from './SlackModels' +import { SlackMessage } from './SlackMessage' +import { SlackMessageRef, SlackParticipant, SlackReaction } from './SlackModels' import { SlackThread } from './SlackThread' export const SlackEventId = Schema.NonEmptyString.pipe(Schema.brand('SlackEventId')) diff --git a/packages/slack/src/SlackChannel.ts b/packages/slack/src/SlackChannel.ts index e30a62f..cc609f8 100644 --- a/packages/slack/src/SlackChannel.ts +++ b/packages/slack/src/SlackChannel.ts @@ -2,14 +2,9 @@ import { Effect, Schema } from 'effect' import type { SlackApiError, SlackFileUploadError } from './SlackApi' import { SlackApi } from './SlackApi' -import { - SlackChannelInfo, - SlackChannelRef, - type SlackContent, - type SlackFile, - type SlackSentMessage, - type SlackUploadFileInput, -} from './SlackModels' +import { SlackFile } from './SlackFile' +import { type SlackSentMessage, slackSentMessageFromRecord } from './SlackMessage' +import { type SlackChannelInfo, SlackChannelRef, type SlackContent, type SlackUploadFileInput } from './SlackModels' const spanAttributes = (channel: SlackChannelRef) => ({ 'slack.team_id': channel.teamId, @@ -22,6 +17,7 @@ export class SlackChannel extends Schema.TaggedClass()('SlackChann }) { post(content: SlackContent): Effect.Effect { return Effect.flatMap(SlackApi, (api) => api.postToChannel({ channel: this.ref, content })).pipe( + Effect.map(slackSentMessageFromRecord), Effect.withSpan('slack.channel.post', { attributes: spanAttributes(this.ref) }), ) } @@ -29,6 +25,7 @@ export class SlackChannel extends Schema.TaggedClass()('SlackChann /** Uploads bytes and shares the file at the channel root. */ uploadFile(input: SlackUploadFileInput): Effect.Effect { return Effect.flatMap(SlackApi, (api) => api.uploadFileToChannel({ channel: this.ref, input })).pipe( + Effect.map((record) => SlackFile.make(record)), Effect.withSpan('slack.channel.upload_file', { attributes: spanAttributes(this.ref) }), ) } diff --git a/packages/slack/src/SlackEventProcessor.ts b/packages/slack/src/SlackEventProcessor.ts index c10b55b..08e0524 100644 --- a/packages/slack/src/SlackEventProcessor.ts +++ b/packages/slack/src/SlackEventProcessor.ts @@ -47,11 +47,11 @@ import { slackReactionTargets, slackThreadSupportedOperations, } from './SlackDeliveryDestination' +import { slackFileFromMetadata } from './SlackFile' import { SlackChannelId, SlackMessageTs, SlackTeamId, slackThreadResourceId } from './SlackIdentity' +import { SlackMessage } from './SlackMessage' import { - slackFileFromMetadata, SlackMarkdownContent, - SlackMessage, SlackMessageRef, type SlackParticipant, SlackReaction, @@ -385,7 +385,7 @@ const normalizeEnvelope = (envelope: SlackEnvelope, thread: SlackThreadRef) => }) const message = yield* Effect.flatMap(SlackApi, (api) => api.getMessage({ thread, message: messageRef }), - ) + ).pipe(Effect.map((record) => SlackMessage.fromRecord(record))) return Option.some({ event: SlackReactionAdded.make({ eventId, @@ -407,7 +407,7 @@ const normalizeEnvelope = (envelope: SlackEnvelope, thread: SlackThreadRef) => }) const message = yield* Effect.flatMap(SlackApi, (api) => api.getMessage({ thread, message: messageRef }), - ) + ).pipe(Effect.map((record) => SlackMessage.fromRecord(record))) return Option.some({ event: SlackReactionRemoved.make({ eventId, diff --git a/packages/slack/src/SlackFile.ts b/packages/slack/src/SlackFile.ts new file mode 100644 index 0000000..5b7da90 --- /dev/null +++ b/packages/slack/src/SlackFile.ts @@ -0,0 +1,56 @@ +import { Effect, Schema, type Stream } from 'effect' + +import { SlackApi } from './SlackApi' +import type { SlackApiError, SlackFileDownloadError } from './SlackApiContract' +import { SlackDownloadFileBytesRequest, SlackDownloadFileRequest } from './SlackApiContract' +import type { SlackTeamId } from './SlackIdentity' +import { type SlackDownloadBytesOptions, SlackFileRecord, slackFileRecordFromMetadata } from './SlackModels' +import type { SlackFileMetadata } from './SlackWebhookEventSchemas' + +/** A Slack file you can download. `SlackApi` returns its fields as a `SlackFileRecord`. */ +export class SlackFile extends Schema.TaggedClass()('SlackFile', SlackFileRecord.fields) { + /** Streams the file with the bot token. The stream is not buffered. */ + download(): Effect.Effect, SlackFileDownloadError, SlackApi> { + return Effect.flatMap(SlackApi, (api) => + api.downloadFile( + SlackDownloadFileRequest.make({ + file: this.ref, + downloadUrl: this.downloadUrl, + contentType: this.contentType, + size: this.size, + }), + ), + ).pipe( + Effect.withSpan('slack.file.download', { + attributes: { 'slack.team_id': this.ref.teamId, 'slack.file_id': this.ref.fileId }, + }), + ) + } + + /** Buffers the file, failing before `maxBytes` is exceeded. */ + downloadBytes(options: SlackDownloadBytesOptions): Effect.Effect { + return Effect.flatMap(SlackApi, (api) => + api.downloadFileBytes( + SlackDownloadFileBytesRequest.make({ + file: this.ref, + downloadUrl: this.downloadUrl, + contentType: this.contentType, + size: this.size, + maxBytes: options.maxBytes, + }), + ), + ).pipe( + Effect.withSpan('slack.file.download_bytes', { + attributes: { + 'slack.team_id': this.ref.teamId, + 'slack.file_id': this.ref.fileId, + 'slack.max_bytes': options.maxBytes, + }, + }), + ) + } +} + +/** Builds a `SlackFile` from webhook or Web API file metadata; see `slackFileRecordFromMetadata`. */ +export const slackFileFromMetadata = (teamId: SlackTeamId, metadata: SlackFileMetadata): SlackFile => + SlackFile.make(slackFileRecordFromMetadata(teamId, metadata)) diff --git a/packages/slack/src/SlackMessage.ts b/packages/slack/src/SlackMessage.ts new file mode 100644 index 0000000..286cc91 --- /dev/null +++ b/packages/slack/src/SlackMessage.ts @@ -0,0 +1,48 @@ +import { Effect, Schema } from 'effect' + +import { SlackApi } from './SlackApi' +import type { SlackApiError } from './SlackApiContract' +import { SlackFile } from './SlackFile' +import { SlackMessageRecord, SlackMessageRef, type SlackReaction, type SlackSentMessageRecord } from './SlackModels' + +const spanAttributes = (ref: SlackMessageRef) => ({ + 'slack.team_id': ref.teamId, + 'slack.channel_id': ref.channelId, + 'slack.message_ts': ref.messageTs, +}) + +/** A Slack message you can react to. `SlackApi` returns its fields as a `SlackMessageRecord`. */ +export class SlackMessage extends Schema.TaggedClass()('SlackMessage', { + ...SlackMessageRecord.fields, + files: Schema.Array(SlackFile), +}) { + /** Wraps a message record from `SlackApi`, and each of its files. */ + static fromRecord(record: SlackMessageRecord): SlackMessage { + return SlackMessage.make({ ...record, files: record.files.map((file) => SlackFile.make(file)) }) + } + + addReaction(reaction: SlackReaction): Effect.Effect { + return Effect.flatMap(SlackApi, (api) => api.addReaction({ message: this.ref, reaction })).pipe( + Effect.withSpan('slack.message.add_reaction', { attributes: spanAttributes(this.ref) }), + ) + } + + removeReaction(reaction: SlackReaction): Effect.Effect { + return Effect.flatMap(SlackApi, (api) => api.removeReaction({ message: this.ref, reaction })).pipe( + Effect.withSpan('slack.message.remove_reaction', { attributes: spanAttributes(this.ref) }), + ) + } +} + +export const SlackMessages = Schema.Array(SlackMessage) +export type SlackMessages = typeof SlackMessages.Type + +export const SlackSentMessage = Schema.Struct({ + ref: SlackMessageRef, + message: SlackMessage, +}) +export type SlackSentMessage = typeof SlackSentMessage.Type + +/** Wraps a sent-message record from `SlackApi`. */ +export const slackSentMessageFromRecord = (record: SlackSentMessageRecord): SlackSentMessage => + SlackSentMessage.make({ ref: record.ref, message: SlackMessage.fromRecord(record.message) }) diff --git a/packages/slack/src/SlackModels.ts b/packages/slack/src/SlackModels.ts index d7a66e2..0e64026 100644 --- a/packages/slack/src/SlackModels.ts +++ b/packages/slack/src/SlackModels.ts @@ -1,9 +1,9 @@ -import { Effect, Schema, type Stream } from 'effect' +/** + * Plain Slack data: refs, ids, content, upload inputs, and the records `SlackApi` takes and returns. Nothing here + * calls a service, so this module imports neither `SlackApi` nor the callback objects built on it. + */ +import { Schema } from 'effect' -import type { SlackApi } from './SlackApi' -import type { SlackApiError, SlackFileDownloadError } from './SlackApiContract' -import { SlackDownloadFileBytesRequest, SlackDownloadFileRequest } from './SlackApiContract' -import { SlackApiHandle } from './SlackApiHandle' import { SlackChannelId, SlackMessageTs, SlackTeamId } from './SlackIdentity' import type { SlackFileMetadata } from './SlackWebhookEventSchemas' @@ -117,63 +117,24 @@ export const SlackUploadFileInput = Schema.Struct({ }) export type SlackUploadFileInput = typeof SlackUploadFileInput.Type -export class SlackFile extends Schema.TaggedClass()('SlackFile', { +/** A Slack file as `SlackApi` returns it: the fields of `SlackFile`, without its methods. */ +export const SlackFileRecord = Schema.Struct({ ref: SlackFileRef, name: Schema.NullOr(Schema.String), contentType: Schema.NullOr(Schema.String), size: Schema.NullOr(Schema.Natural), downloadUrl: Schema.NullOr(Schema.String), -}) { - /** Streams the file with the bot token. The stream is not buffered. */ - download(): Effect.Effect, SlackFileDownloadError, SlackApi> { - return Effect.flatMap(SlackApiHandle, (api) => - api.downloadFile( - SlackDownloadFileRequest.make({ - file: this.ref, - downloadUrl: this.downloadUrl, - contentType: this.contentType, - size: this.size, - }), - ), - ).pipe( - Effect.withSpan('slack.file.download', { - attributes: { 'slack.team_id': this.ref.teamId, 'slack.file_id': this.ref.fileId }, - }), - ) - } - - /** Buffers the file, failing before `maxBytes` is exceeded. */ - downloadBytes(options: SlackDownloadBytesOptions): Effect.Effect { - return Effect.flatMap(SlackApiHandle, (api) => - api.downloadFileBytes( - SlackDownloadFileBytesRequest.make({ - file: this.ref, - downloadUrl: this.downloadUrl, - contentType: this.contentType, - size: this.size, - maxBytes: options.maxBytes, - }), - ), - ).pipe( - Effect.withSpan('slack.file.download_bytes', { - attributes: { - 'slack.team_id': this.ref.teamId, - 'slack.file_id': this.ref.fileId, - 'slack.max_bytes': options.maxBytes, - }, - }), - ) - } -} +}) +export type SlackFileRecord = typeof SlackFileRecord.Type const nonEmpty = (value: string | undefined) => (value === undefined || value.length === 0 ? null : value) /** - * Normalizes Slack file metadata from webhooks, Web API messages, and upload completion into one public value. + * Normalizes Slack file metadata from webhooks, Web API messages, and upload completion into one record. * `url_private_download` wins over `url_private`; absent or empty provider fields become null. */ -export const slackFileFromMetadata = (teamId: SlackTeamId, metadata: SlackFileMetadata) => - SlackFile.make({ +export const slackFileRecordFromMetadata = (teamId: SlackTeamId, metadata: SlackFileMetadata): SlackFileRecord => + SlackFileRecord.make({ ref: SlackFileRef.make({ teamId, fileId: SlackFileId.make(metadata.id) }), name: nonEmpty(metadata.name), contentType: nonEmpty(metadata.mimetype), @@ -181,7 +142,8 @@ export const slackFileFromMetadata = (teamId: SlackTeamId, metadata: SlackFileMe downloadUrl: nonEmpty(metadata.url_private_download) ?? nonEmpty(metadata.url_private), }) -export class SlackMessage extends Schema.TaggedClass()('SlackMessage', { +/** A Slack message as `SlackApi` returns it: the fields of `SlackMessage`, without its methods. */ +export const SlackMessageRecord = Schema.Struct({ ref: SlackMessageRef, thread: SlackThreadRef, author: SlackParticipant, @@ -191,45 +153,23 @@ export class SlackMessage extends Schema.TaggedClass()('SlackMessa */ authorTeamId: Schema.optionalKey(SlackTeamId), content: SlackContent, - files: Schema.Array(SlackFile), + files: Schema.Array(SlackFileRecord), metadata: SlackMetadata, -}) { - addReaction(reaction: SlackReaction): Effect.Effect { - return Effect.flatMap(SlackApiHandle, (api) => api.addReaction({ message: this.ref, reaction })).pipe( - Effect.withSpan('slack.message.add_reaction', { - attributes: { - 'slack.team_id': this.ref.teamId, - 'slack.channel_id': this.ref.channelId, - 'slack.message_ts': this.ref.messageTs, - }, - }), - ) - } - - removeReaction(reaction: SlackReaction): Effect.Effect { - return Effect.flatMap(SlackApiHandle, (api) => api.removeReaction({ message: this.ref, reaction })).pipe( - Effect.withSpan('slack.message.remove_reaction', { - attributes: { - 'slack.team_id': this.ref.teamId, - 'slack.channel_id': this.ref.channelId, - 'slack.message_ts': this.ref.messageTs, - }, - }), - ) - } -} - -export const SlackMessages = Schema.Array(SlackMessage) -export type SlackMessages = typeof SlackMessages.Type +}) +export type SlackMessageRecord = typeof SlackMessageRecord.Type + +export const SlackMessageRecords = Schema.Array(SlackMessageRecord) +export type SlackMessageRecords = typeof SlackMessageRecords.Type export const SlackParticipants = Schema.Array(SlackParticipant) export type SlackParticipants = typeof SlackParticipants.Type -export const SlackSentMessage = Schema.Struct({ +/** A message `SlackApi` posted, as it returns it. */ +export const SlackSentMessageRecord = Schema.Struct({ ref: SlackMessageRef, - message: SlackMessage, + message: SlackMessageRecord, }) -export type SlackSentMessage = typeof SlackSentMessage.Type +export type SlackSentMessageRecord = typeof SlackSentMessageRecord.Type export const SlackThreadInfo = Schema.Struct({ thread: SlackThreadRef, diff --git a/packages/slack/src/SlackThread.ts b/packages/slack/src/SlackThread.ts index de6ead5..e7f3331 100644 --- a/packages/slack/src/SlackThread.ts +++ b/packages/slack/src/SlackThread.ts @@ -8,16 +8,15 @@ import { Effect, Schema, Stream } from 'effect' import type { SlackApiError, SlackFileUploadError } from './SlackApi' import { SlackApi } from './SlackApi' import { SlackChannel } from './SlackChannel' +import { SlackFile } from './SlackFile' +import { SlackMessage, type SlackMessages, type SlackSentMessage, slackSentMessageFromRecord } from './SlackMessage' import { type SlackChannelInfo, SlackChannelRef, type SlackContent, - type SlackFile, type SlackMessageCount, - type SlackMessages, type SlackParticipants, - type SlackSentMessage, - SlackThreadInfo, + type SlackThreadInfo, SlackThreadRef, type SlackUploadFileInput, } from './SlackModels' @@ -57,6 +56,7 @@ export class SlackThread extends Schema.TaggedClass()('SlackThread' listMessages(): Effect.Effect { return Effect.flatMap(SlackApi, (api) => api.listThreadMessages({ thread: this.ref })).pipe( + Effect.map((records) => records.map((record) => SlackMessage.fromRecord(record))), Effect.withSpan('slack.thread.list_messages', { attributes: spanAttributes(this.ref) }), ) } @@ -72,6 +72,7 @@ export class SlackThread extends Schema.TaggedClass()('SlackThread' ) } return Effect.flatMap(SlackApi, (api) => api.listChannelMessagesBeforeThread({ thread: this.ref, count })).pipe( + Effect.map((records) => records.map((record) => SlackMessage.fromRecord(record))), Effect.withSpan('slack.thread.list_channel_messages_before_thread', { attributes: spanAttributes(this.ref), }), @@ -80,6 +81,7 @@ export class SlackThread extends Schema.TaggedClass()('SlackThread' post(content: SlackContent): Effect.Effect { return Effect.flatMap(SlackApi, (api) => api.postToThread({ thread: this.ref, content })).pipe( + Effect.map(slackSentMessageFromRecord), Effect.withSpan('slack.thread.post', { attributes: spanAttributes(this.ref) }), ) } @@ -87,6 +89,7 @@ export class SlackThread extends Schema.TaggedClass()('SlackThread' /** Uploads bytes and shares the file as a reply in this thread. */ uploadFile(input: SlackUploadFileInput): Effect.Effect { return Effect.flatMap(SlackApi, (api) => api.uploadFileToThread({ thread: this.ref, input })).pipe( + Effect.map((record) => SlackFile.make(record)), Effect.withSpan('slack.thread.upload_file', { attributes: spanAttributes(this.ref) }), ) } @@ -101,6 +104,7 @@ export class SlackThread extends Schema.TaggedClass()('SlackThread' chunks: Stream.Stream, ): Effect.Effect { return Effect.flatMap(SlackApi, (api) => api.stream(this.ref, chunks)).pipe( + Effect.map(slackSentMessageFromRecord), Effect.withSpan('slack.thread.stream', { attributes: spanAttributes(this.ref) }), ) } diff --git a/packages/slack/src/index.ts b/packages/slack/src/index.ts index 94fd74a..2ab7f4a 100644 --- a/packages/slack/src/index.ts +++ b/packages/slack/src/index.ts @@ -3,6 +3,8 @@ export * from './SlackEventProcessor' export * from './SlackWebhookEventSchemas' export * from './SlackIdentity' export * from './SlackModels' +export * from './SlackFile' +export * from './SlackMessage' export * from './SlackStreamChunk' export * from './SlackApi' export * from './SlackApiLive' diff --git a/packages/slack/test/slack-api-file-download.vi.test.ts b/packages/slack/test/slack-api-file-download.vi.test.ts index 9df49c2..3492b3c 100644 --- a/packages/slack/test/slack-api-file-download.vi.test.ts +++ b/packages/slack/test/slack-api-file-download.vi.test.ts @@ -2,7 +2,7 @@ import { describe, it } from '@effect/vitest' import { Effect, Queue, Stream } from 'effect' import { SlackApiError, SlackFileAuthorizationError, SlackFileSizeLimitExceeded } from '../src/SlackApi' -import { SlackFile, slackFileFromMetadata } from '../src/SlackModels' +import { SlackFile, slackFileFromMetadata } from '../src/SlackFile' import { botToken, makeRecordingSlackHttp, diff --git a/packages/slack/test/slack-api-file-upload.vi.test.ts b/packages/slack/test/slack-api-file-upload.vi.test.ts index e14333b..f8962bf 100644 --- a/packages/slack/test/slack-api-file-upload.vi.test.ts +++ b/packages/slack/test/slack-api-file-upload.vi.test.ts @@ -2,7 +2,7 @@ import { describe, it } from '@effect/vitest' import { Effect, Match, Queue, Schema } from 'effect' import { SlackApi, SlackApiError, SlackFileAuthorizationError, type SlackFileUploadError } from '../src/SlackApi' -import { SlackFile, SlackFileId, SlackFileRef, type SlackUploadFileInput } from '../src/SlackModels' +import { SlackFileId, SlackFileRecord, SlackFileRef, type SlackUploadFileInput } from '../src/SlackModels' import { botToken, channelRef, @@ -49,7 +49,7 @@ const runUpload = ( script: UploadScript, target: UploadTarget, uploadInput: SlackUploadFileInput, - finish: (effect: Effect.Effect) => Effect.Effect, + finish: (effect: Effect.Effect) => Effect.Effect, ) => Effect.gen(function* () { const requests = yield* Queue.unbounded() @@ -107,7 +107,7 @@ describe('Slack file upload', () => { initial_comment: 'Here it is', }) expect(file).toEqual( - SlackFile.make({ + SlackFileRecord.make({ ref: SlackFileRef.make({ teamId, fileId: SlackFileId.make(fileId) }), name: 'notes.txt', contentType: 'text/plain', diff --git a/packages/slack/test/slack-api-pagination.vi.test.ts b/packages/slack/test/slack-api-pagination.vi.test.ts index fdf2371..56483e6 100644 --- a/packages/slack/test/slack-api-pagination.vi.test.ts +++ b/packages/slack/test/slack-api-pagination.vi.test.ts @@ -5,16 +5,17 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from 'effect/http' import { SlackApi } from '../src/SlackApi' import { SlackApiLiveBase } from '../src/SlackApiLive' import { SlackChannelId, SlackMessageTs, SlackTeamId } from '../src/SlackIdentity' +import { SlackMessage, slackSentMessageFromRecord } from '../src/SlackMessage' import { SlackChannelInfo, SlackChannelRef, - slackFileFromMetadata, + slackFileRecordFromMetadata, SlackMarkdownContent, - SlackMessage, SlackMessageCount, + SlackMessageRecord, SlackMessageRef, SlackParticipant, - SlackSentMessage, + SlackSentMessageRecord, SlackThreadRef, SlackUserId, } from '../src/SlackModels' @@ -59,16 +60,16 @@ const me = SlackParticipant.make({ const message = (timestamp: string, author = alice) => { const ref = SlackMessageRef.make({ teamId, channelId, messageTs: SlackMessageTs.make(timestamp) }) - return SlackMessage.make({ ref, thread: threadRef, author, content, files: [], metadata: {} }) + return SlackMessageRecord.make({ ref, thread: threadRef, author, content, files: [], metadata: {} }) } const rootMessage = message(threadTs) const sentMessage = message(threadTs, me) -const sent = SlackSentMessage.make({ ref: sentMessage.ref, message: sentMessage }) +const sent = SlackSentMessageRecord.make({ ref: sentMessage.ref, message: sentMessage }) const channelInfo = SlackChannelInfo.make({ channel: channelRef, name: 'pagination', memberCount: 4 }) type SlackMessagePage = { - readonly messages: ReadonlyArray + readonly messages: ReadonlyArray readonly nextCursor?: string } @@ -256,12 +257,12 @@ describe('SlackApi paginated thread layer', () => { const harness = yield* makeHarness() const plain = message('1700000001.000000') const base = message('1700000002.000000') - const withFile = SlackMessage.make({ + const withFile = SlackMessageRecord.make({ ref: base.ref, thread: base.thread, author: base.author, content: base.content, - files: [slackFileFromMetadata(teamId, slackFileMetadata)], + files: [slackFileRecordFromMetadata(teamId, slackFileMetadata)], metadata: base.metadata, }) yield* Queue.offer(harness.threadPages, { messages: [withFile, plain] }) @@ -334,8 +335,10 @@ describe('SlackApi paginated thread layer', () => { yield* thread .listChannelMessagesBeforeThread(SlackMessageCount.make(1)) .pipe(Effect.provide(harness.layer)), - ).toEqual([prior]) - expect(yield* thread.channel.post(content).pipe(Effect.provide(harness.layer))).toEqual(sent) + ).toEqual([SlackMessage.fromRecord(prior)]) + expect(yield* thread.channel.post(content).pipe(Effect.provide(harness.layer))).toEqual( + slackSentMessageFromRecord(sent), + ) expect(yield* thread.channel.fetchInfo().pipe(Effect.provide(harness.layer))).toEqual(channelInfo) expect(yield* Queue.take(harness.calls)).toEqual({ diff --git a/packages/slack/test/slack-event-processing.vi.test.ts b/packages/slack/test/slack-event-processing.vi.test.ts index 46eee03..f87b26b 100644 --- a/packages/slack/test/slack-event-processing.vi.test.ts +++ b/packages/slack/test/slack-event-processing.vi.test.ts @@ -34,14 +34,9 @@ import { } from '../src/SlackCallbackEvents' import { type SlackCallbackHandler, type SlackCallbackHandlers } from '../src/SlackCallbacks' import { makeSlackEventProcessor } from '../src/SlackEventProcessor' +import { slackFileFromMetadata } from '../src/SlackFile' import { SlackChannelId, SlackMessageTs, SlackTeamId, slackThreadResourceId } from '../src/SlackIdentity' -import { - slackFileFromMetadata, - SlackMarkdownContent, - SlackMessage, - SlackParticipant, - SlackUserId, -} from '../src/SlackModels' +import { SlackMarkdownContent, SlackMessageRecord, SlackParticipant, SlackUserId } from '../src/SlackModels' import { makeSlackWebhookProvider } from '../src/SlackWebhookProvider' import { SlackAgentSessionStoppedEnvelope, @@ -139,7 +134,7 @@ const apiLayer = Layer.mock(SlackApi, { resolveParticipant: (request) => Effect.succeed(request.userId === agent.userId ? agent : alice), getMessage: (request) => Effect.succeed( - SlackMessage.make({ + SlackMessageRecord.make({ ref: request.message, thread: request.thread, author: alice, diff --git a/packages/slack/test/slack-file.vi.test.ts b/packages/slack/test/slack-file.vi.test.ts index b875e3b..3f73a17 100644 --- a/packages/slack/test/slack-file.vi.test.ts +++ b/packages/slack/test/slack-file.vi.test.ts @@ -3,13 +3,13 @@ import { Effect, Exit, Layer, Queue, Schema, Stream } from 'effect' import { SlackApi, SlackDownloadFileBytesRequest, SlackDownloadFileRequest } from '../src/SlackApi' import { SlackChannel } from '../src/SlackChannel' +import { SlackFile, slackFileFromMetadata } from '../src/SlackFile' +import { SlackMessage } from '../src/SlackMessage' import { - SlackFile, SlackFileId, SlackFileRef, - slackFileFromMetadata, + slackFileRecordFromMetadata, SlackMarkdownContent, - SlackMessage, SlackMessageRef, SlackParticipant, SlackUploadFileInput, @@ -32,6 +32,7 @@ import { const ref = SlackFileRef.make({ teamId, fileId: SlackFileId.make(fileId) }) const file = slackFileFromMetadata(teamId, slackFileMetadata) +const fileRecord = slackFileRecordFromMetadata(teamId, slackFileMetadata) const bytes = new TextEncoder().encode('hello') describe('SlackFile values', () => { @@ -112,9 +113,9 @@ describe('Slack file resources', () => { downloadFileBytes: (request) => Queue.offer(calls, { operation: 'downloadFileBytes', request }).pipe(Effect.as(bytes)), uploadFileToChannel: (request) => - Queue.offer(calls, { operation: 'uploadFileToChannel', request }).pipe(Effect.as(file)), + Queue.offer(calls, { operation: 'uploadFileToChannel', request }).pipe(Effect.as(fileRecord)), uploadFileToThread: (request) => - Queue.offer(calls, { operation: 'uploadFileToThread', request }).pipe(Effect.as(file)), + Queue.offer(calls, { operation: 'uploadFileToThread', request }).pipe(Effect.as(fileRecord)), }) const input = SlackUploadFileInput.make({ filename: 'notes.txt', bytes }) const download = { @@ -126,10 +127,13 @@ describe('Slack file resources', () => { expect(yield* file.download().pipe(Effect.flatMap(Stream.runCollect), Effect.provide(api))).toHaveLength(1) yield* file.downloadBytes({ maxBytes: 100 }).pipe(Effect.provide(api)) - yield* SlackChannel.make({ ref: channelRef }).uploadFile(input).pipe(Effect.provide(api)) - yield* SlackThread.make({ ref: threadRef, mailboxKey: 'mailbox:file' }) + expect(yield* SlackChannel.make({ ref: channelRef }).uploadFile(input).pipe(Effect.provide(api))).toEqual( + file, + ) + const threadUpload = yield* SlackThread.make({ ref: threadRef, mailboxKey: 'mailbox:file' }) .uploadFile(input) .pipe(Effect.provide(api)) + expect(threadUpload).toBeInstanceOf(SlackFile) expect(Array.from(yield* Queue.clear(calls))).toEqual([ { operation: 'downloadFile', request: SlackDownloadFileRequest.make(download) }, diff --git a/packages/slack/test/slack-thread-context.vi.test.ts b/packages/slack/test/slack-thread-context.vi.test.ts index 4b3b14a..a5ddbe3 100644 --- a/packages/slack/test/slack-thread-context.vi.test.ts +++ b/packages/slack/test/slack-thread-context.vi.test.ts @@ -8,16 +8,17 @@ import { Effect, Layer, Option, Queue, Stream } from 'effect' import { SlackApi } from '../src/SlackApi' import { SlackChannelId, SlackMessageTs, SlackTeamId } from '../src/SlackIdentity' +import { SlackMessage, slackSentMessageFromRecord } from '../src/SlackMessage' import { SlackChannelInfo, SlackMarkdownContent, - SlackMessage, SlackMessageCount, + SlackMessageRecord, SlackMessageRef, SlackParticipant, SlackPlainTextContent, SlackReaction, - SlackSentMessage, + SlackSentMessageRecord, SlackThreadInfo, SlackThreadRef, SlackUserId, @@ -45,7 +46,7 @@ const arbitraryMessageRef = SlackMessageRef.make({ channelId, messageTs: SlackMessageTs.make('1700000001.000001'), }) -const message = SlackMessage.make({ +const messageRecord = SlackMessageRecord.make({ ref: rootMessageRef, thread: threadRef, author: participant, @@ -53,7 +54,9 @@ const message = SlackMessage.make({ files: [], metadata: { source: 'test', ordinal: 1 }, }) -const sent = SlackSentMessage.make({ ref: rootMessageRef, message }) +const sentRecord = SlackSentMessageRecord.make({ ref: rootMessageRef, message: messageRecord }) +const message = SlackMessage.fromRecord(messageRecord) +const sent = slackSentMessageFromRecord(sentRecord) const threadInfo = SlackThreadInfo.make({ thread: threadRef, title: 'Context thread' }) const channelInfo = SlackChannelInfo.make({ channel: { teamId, channelId, isDm: false }, @@ -70,11 +73,11 @@ const makeApi = ( listParticipants: (request) => Queue.offer(calls, { operation: 'listParticipants', request }).pipe(Effect.as([participant])), listThreadMessages: (request) => - Queue.offer(calls, { operation: 'listThreadMessages', request }).pipe(Effect.as([message])), + Queue.offer(calls, { operation: 'listThreadMessages', request }).pipe(Effect.as([messageRecord])), listChannelMessagesBeforeThread: (request) => - Queue.offer(calls, { operation: 'listChannelMessagesBeforeThread', request }).pipe(Effect.as([message])), - postToThread: (request) => Queue.offer(calls, { operation: 'postToThread', request }).pipe(Effect.as(sent)), - postToChannel: (request) => Queue.offer(calls, { operation: 'postToChannel', request }).pipe(Effect.as(sent)), + Queue.offer(calls, { operation: 'listChannelMessagesBeforeThread', request }).pipe(Effect.as([messageRecord])), + postToThread: (request) => Queue.offer(calls, { operation: 'postToThread', request }).pipe(Effect.as(sentRecord)), + postToChannel: (request) => Queue.offer(calls, { operation: 'postToChannel', request }).pipe(Effect.as(sentRecord)), updateMessage: () => unexpected('updateMessage'), setThreadStatus: () => unexpected('setThreadStatus'), clearThreadStatus: () => unexpected('clearThreadStatus'), @@ -85,7 +88,7 @@ const makeApi = ( Effect.flatMap((collected) => Queue.offer(calls, { operation: 'stream', thread: requestedThread, chunks: Array.from(collected) }), ), - Effect.as(sent), + Effect.as(sentRecord), ), postPlanToThread: () => unexpected('postPlanToThread'), updatePlan: () => unexpected('updatePlan'),