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'),