-
Notifications
You must be signed in to change notification settings - Fork 0
fix(linear-slack): actually create Linear issues (stop fabricating them) #54
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,152 @@ | ||
| import assert from 'node:assert/strict'; | ||
| import test from 'node:test'; | ||
|
|
||
| import { handleSlackEvent } from '../.test-build/linear-slack/agent.js'; | ||
|
|
||
| function ctx(overrides = {}) { | ||
| const logs = []; | ||
| const memorySaves = []; | ||
| return { | ||
| logs, | ||
| memorySaves, | ||
| persona: { id: 'linear-slack', inputs: {}, inputSpecs: {} }, | ||
| sandbox: { cwd: '/home/daytona/workspace' }, | ||
| memory: { | ||
| recall: async () => [], | ||
| save: async (content, opts) => { memorySaves.push({ content, opts }); return { id: 'm1' }; }, | ||
| }, | ||
| harness: { run: async () => ({ output: overrides.harnessOutput ?? '' }) }, | ||
| log: (level, message, attrs) => logs.push({ level, message, attrs }), | ||
| ...overrides, | ||
| }; | ||
| } | ||
|
|
||
| function slackSpy() { | ||
| const posts = []; | ||
| return { | ||
| posts, | ||
| async post(channel, text) { posts.push({ channel, text }); return { channel, ts: 'ts-1' }; }, | ||
| async reply(channel, threadTs, text) { posts.push({ channel, threadTs, text }); return { channel, ts: 'ts-1' }; }, | ||
| }; | ||
| } | ||
|
|
||
| function linearSpy(overrides = {}) { | ||
| const created = []; | ||
| const comments = []; | ||
| return { | ||
| created, | ||
| comments, | ||
| async createIssue(args) { | ||
| created.push(args); | ||
| return overrides.createIssue ?? { id: 'AR-84', url: 'https://linear.app/agentrelay/issue/AR-84/remove-dashboard' }; | ||
| }, | ||
| async comment(issueId, body) { | ||
| comments.push({ issueId, body }); | ||
| return overrides.comment ?? { id: 'c1', url: 'https://linear.app/agentrelay/issue/AR-10#comment-c1' }; | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| function slackEvent(text) { | ||
| return { | ||
| source: 'slack', | ||
| id: 'evt-1', | ||
| type: 'message.created', | ||
| payload: { channel: 'C0B9287EP6Y', ts: '1781004465.912899', text, user: 'U1', is_bot: false }, | ||
| }; | ||
| } | ||
|
|
||
| const ACTION = (obj) => '```linear-actions\n' + JSON.stringify(obj) + '\n```'; | ||
|
|
||
| test('create_issue action runs through linearClient and reports the CONFIRMED url', async () => { | ||
| const runtime = ctx({ | ||
| harnessOutput: `Creating that issue in Launch SDK now.\n\n${ACTION([ | ||
| { action: 'create_issue', teamId: 'team-uuid', title: 'Remove the dashboard from the agent-relay up command', projectId: 'proj-uuid', description: 'desc' }, | ||
| ])}`, | ||
| }); | ||
| const slack = slackSpy(); | ||
| const linear = linearSpy(); | ||
|
|
||
| await handleSlackEvent(runtime, slackEvent('make an issue to remove the dashboard'), slack, linear); | ||
|
|
||
| // the real Linear writeback was invoked with allow-listed fields + required ids | ||
| assert.equal(linear.created.length, 1); | ||
| assert.deepEqual(linear.created[0], { | ||
| teamId: 'team-uuid', title: 'Remove the dashboard from the agent-relay up command', | ||
| description: 'desc', projectId: 'proj-uuid', | ||
| }); | ||
| // the reply carries the prose AND the confirmed link, and the action block is gone | ||
| const posted = slack.posts.at(-1).text; | ||
| assert.match(posted, /Creating that issue in Launch SDK now\./); | ||
| assert.match(posted, /✅ Created the issue: https:\/\/linear\.app\/agentrelay\/issue\/AR-84/); | ||
| assert.doesNotMatch(posted, /linear-actions/); | ||
| // the recorded turn is the confirmed reply, not the harness's raw output | ||
| assert.ok(runtime.memorySaves.some((s) => /✅ Created the issue/.test(s.content))); | ||
| }); | ||
|
|
||
| test('an unconfirmed create (no receipt → draft-path fallback) is flagged, never claimed done', async () => { | ||
| const runtime = ctx({ | ||
| harnessOutput: `On it.\n\n${ACTION([{ action: 'create_issue', teamId: 't', title: 'x' }])}`, | ||
| }); | ||
| const slack = slackSpy(); | ||
| // url falls back to the draft path when the writeback worker never acks | ||
| const linear = linearSpy({ createIssue: { id: '/linear/issues/issues abc.json', url: '/linear/issues/issues abc.json' } }); | ||
|
|
||
| await handleSlackEvent(runtime, slackEvent('make an issue'), slack, linear); | ||
|
|
||
| const posted = slack.posts.at(-1).text; | ||
| assert.doesNotMatch(posted, /✅/); | ||
| assert.match(posted, /never confirmed|double-check/i); | ||
| assert.ok(runtime.logs.some((l) => l.message === 'linear-slack.action.unconfirmed')); | ||
| }); | ||
|
|
||
| test('create_issue missing teamId is refused without calling Linear', async () => { | ||
| const runtime = ctx({ | ||
| harnessOutput: `${ACTION([{ action: 'create_issue', title: 'no team' }])}`, | ||
| }); | ||
| const slack = slackSpy(); | ||
| const linear = linearSpy(); | ||
|
|
||
| await handleSlackEvent(runtime, slackEvent('make an issue'), slack, linear); | ||
|
|
||
| assert.equal(linear.created.length, 0); | ||
| assert.match(slack.posts.at(-1).text, /missing `teamId`/); | ||
| }); | ||
|
|
||
| test('comment action posts through linearClient and confirms', async () => { | ||
| const runtime = ctx({ | ||
| harnessOutput: ACTION([{ action: 'comment', issueId: 'issue-uuid', body: 'looks good' }]), | ||
| }); | ||
| const slack = slackSpy(); | ||
| const linear = linearSpy(); | ||
|
|
||
| await handleSlackEvent(runtime, slackEvent('comment on AR-10'), slack, linear); | ||
|
|
||
| assert.deepEqual(linear.comments, [{ issueId: 'issue-uuid', body: 'looks good' }]); | ||
| assert.match(slack.posts.at(-1).text, /✅ Added the comment/); | ||
| }); | ||
|
|
||
| test('a read-only / discussion turn posts prose and triggers NO writes', async () => { | ||
| const runtime = ctx({ harnessOutput: 'There are 3 open issues in Launch SDK: AR-10, AR-11, AR-17.' }); | ||
| const slack = slackSpy(); | ||
| const linear = linearSpy(); | ||
|
|
||
| await handleSlackEvent(runtime, slackEvent('what is open in launch sdk?'), slack, linear); | ||
|
|
||
| assert.equal(linear.created.length, 0); | ||
| assert.equal(linear.comments.length, 0); | ||
| assert.equal(slack.posts.at(-1).text, 'There are 3 open issues in Launch SDK: AR-10, AR-11, AR-17.'); | ||
| }); | ||
|
|
||
| test('a malformed action block changes nothing and says so', async () => { | ||
| const runtime = ctx({ | ||
| harnessOutput: 'Trying.\n\n```linear-actions\n{ not valid json,, }\n```', | ||
| }); | ||
| const slack = slackSpy(); | ||
| const linear = linearSpy(); | ||
|
|
||
| await handleSlackEvent(runtime, slackEvent('make an issue'), slack, linear); | ||
|
|
||
| assert.equal(linear.created.length, 0); | ||
| assert.match(slack.posts.at(-1).text, /malformed/); | ||
| }); |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Currently, any invalid action objects (e.g., missing the
actionproperty, or non-object elements likenull) are silently filtered out and ignored. This can lead to partial execution of a multi-action block without any warning or error being surfaced to the user.To prevent silent failures, we should validate that every element in the parsed actions array is a valid
LinearAction. If any element is invalid, we should treat the entire block as malformed so that the user/LLM is alerted and no partial state is committed.