Skip to content

Commit 26aafe7

Browse files
committed
feat(linear): pre-hydrate recent human comments into task description (ADR-016)
The agent has no Linear MCP to read the issue thread at runtime, so fetch recent HUMAN comments at admission time and fold them into the task description under a '## Recent comments' heading — mirroring the Jira processor (aws-samples#577). - linear-feedback.fetchRecentComments: direct GraphQL (reuses existing token/graphqlData infra), filters app/integration comments (botActor present or no user) + bot-prefixed bodies (isBotAuthoredComment), oldest-first, capped, fail-open (any failure → []). - processor: screenCommentsOrDrop screens the folded block through the Bedrock Guardrail (fail-open — third-party text that trips policy is dropped, never the reporter's task); buildTaskDescription folds within MAX_TASK_DESCRIPTION_LENGTH with truncation, mirroring Jira. Aligns the Linear test suite to the Jira pattern (guardrail/S3 configured by default, unconfigured case re-imports), which also adds the first processor-level coverage of the P4.1 authenticated-attachment path.
1 parent be79d34 commit 26aafe7

4 files changed

Lines changed: 450 additions & 7 deletions

File tree

cdk/src/handlers/linear-webhook-processor.ts

Lines changed: 118 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
*/
1919

2020
import * as crypto from 'crypto';
21-
import { BedrockRuntimeClient } from '@aws-sdk/client-bedrock-runtime';
21+
import { BedrockRuntimeClient, ApplyGuardrailCommand } from '@aws-sdk/client-bedrock-runtime';
2222
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
2323
import { S3Client } from '@aws-sdk/client-s3';
2424
import { DynamoDBDocumentClient, GetCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb';
@@ -30,6 +30,7 @@ import { renderMaturingReply } from './shared/iteration-reply';
3030
import { cleanupPreScreenedAttachments, downloadScreenAndStoreLinearAttachments, LinearAttachmentError } from './shared/linear-attachments';
3131
import {
3232
deleteComment,
33+
fetchRecentComments,
3334
reactToComment,
3435
replyToComment,
3536
reportIssueFailure,
@@ -41,6 +42,7 @@ import {
4142
EMOJI_STARTED,
4243
EMOJI_SUCCESS,
4344
EMOJI_NEEDS_INPUT,
45+
type RenderedComment,
4446
} from './shared/linear-feedback';
4547
import {
4648
probeLinearIssueContext,
@@ -113,6 +115,7 @@ import { readConcurrencyBudget, releaseReadyChildren } from './shared/orchestrat
113115
import { upsertEpicPanel } from './shared/orchestration-rollup';
114116
import { claimCommentAck, clearRollupClaim, deriveOrchestrationId, loadOrchestration, setStatusCommentId, type OrchestrationReleaseContext } from './shared/orchestration-store';
115117
import type { Attachment, PassedAttachmentRecord } from './shared/types';
118+
import { MAX_TASK_DESCRIPTION_LENGTH } from './shared/validation';
116119
import { CODING_WORKFLOW_ID } from './shared/workflows';
117120

118121
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
@@ -900,7 +903,22 @@ export async function handler(event: ProcessorEvent): Promise<void> {
900903
}
901904
}
902905

903-
const taskDescription = buildTaskDescription(issue, contextHint);
906+
// ADR-016 pre-hydration: fetch recent HUMAN comments and fold them into the
907+
// task description — the agent has no Linear MCP to read the thread at
908+
// runtime. Advisory + fail-open end to end: a fetch failure yields no
909+
// comments, and third-party comment text that trips the guardrail is dropped
910+
// (never the task; the reporter-authored description is screened separately by
911+
// createTaskCore). Mirrors the Jira processor (#619/#577).
912+
let recentComments: RenderedComment[] = [];
913+
if (WORKSPACE_REGISTRY_TABLE && resolvedAccessToken) {
914+
const fetched = await fetchRecentComments(
915+
{ linearWorkspaceId: workspaceId, registryTableName: WORKSPACE_REGISTRY_TABLE },
916+
issue.id,
917+
);
918+
recentComments = await screenCommentsOrDrop(fetched, issue.id, workspaceId);
919+
}
920+
921+
const taskDescription = buildTaskDescription(issue, contextHint, recentComments);
904922

905923
// Extract embedded image URLs from the issue description markdown. Non-Linear
906924
// (public CDN) images become URL attachments fetched+screened during context
@@ -3021,7 +3039,11 @@ function buildRevisionTaskDescription(issueText: string, pending: PendingPlan, f
30213039
].join('\n');
30223040
}
30233041

3024-
function buildTaskDescription(issue: LinearIssueEvent['data'], contextHint: string = ''): string {
3042+
function buildTaskDescription(
3043+
issue: LinearIssueEvent['data'],
3044+
contextHint: string = '',
3045+
comments: readonly RenderedComment[] = [],
3046+
): string {
30253047
const parts: string[] = [];
30263048
if (issue.identifier && issue.title) {
30273049
parts.push(`${issue.identifier}: ${issue.title}`);
@@ -3036,7 +3058,99 @@ function buildTaskDescription(issue: LinearIssueEvent['data'], contextHint: stri
30363058
parts.push('');
30373059
parts.push(issue.description.trim());
30383060
}
3039-
return parts.join('\n') || 'Linear issue';
3061+
const core = parts.join('\n') || 'Linear issue';
3062+
3063+
// Fold recent human comments under a clear heading so the agent can tell them
3064+
// from the description (ADR-016 pre-hydration). Comments are ADVISORY and must
3065+
// stay fail-open: they must never grow the description past
3066+
// MAX_TASK_DESCRIPTION_LENGTH and turn createTaskCore's length check into a
3067+
// hard rejection. Only append what fits the remaining budget, truncating the
3068+
// section if needed. Mirrors the Jira processor.
3069+
if (comments.length === 0) return core;
3070+
const commentSection = renderCommentSection(comments);
3071+
const separator = '\n';
3072+
const budget = MAX_TASK_DESCRIPTION_LENGTH - core.length - separator.length;
3073+
if (budget <= 0) return core; // description already fills the budget — drop comments
3074+
const fitted = commentSection.length <= budget
3075+
? commentSection
3076+
: truncateCommentSection(commentSection, budget);
3077+
return fitted ? core + separator + fitted : core;
3078+
}
3079+
3080+
/** Notice appended when the comment section is truncated to fit the budget. */
3081+
const COMMENT_TRUNCATION_NOTICE = '\n\n_(recent comments truncated)_';
3082+
3083+
function renderCommentSection(comments: readonly RenderedComment[]): string {
3084+
const lines: string[] = ['', '## Recent comments'];
3085+
for (const c of comments) {
3086+
lines.push('');
3087+
const attribution = c.createdAt ? `**${c.author}** (${c.createdAt}):` : `**${c.author}**:`;
3088+
lines.push(attribution);
3089+
lines.push(c.markdown);
3090+
}
3091+
return lines.join('\n');
3092+
}
3093+
3094+
/**
3095+
* Trim a rendered comment section to at most ``budget`` characters, leaving room
3096+
* for a truncation notice. Returns '' if even the heading + notice can't fit, so
3097+
* the caller cleanly drops the section. Mirrors the Jira processor.
3098+
*/
3099+
function truncateCommentSection(section: string, budget: number): string {
3100+
const room = budget - COMMENT_TRUNCATION_NOTICE.length;
3101+
if (room <= 0) return '';
3102+
return section.slice(0, room) + COMMENT_TRUNCATION_NOTICE;
3103+
}
3104+
3105+
/**
3106+
* Screen the rendered comment block through the Bedrock Guardrail on its own, so
3107+
* third-party comment content that trips the policy is DROPPED (fail-open)
3108+
* rather than gating the reporter's task. Returns the comments unchanged when
3109+
* they pass, and ``[]`` when the guardrail intervenes or is unavailable — the
3110+
* task still proceeds with the reporter-authored title/description (which
3111+
* createTaskCore screens separately). Keeps the comment-enrichment contract
3112+
* fail-open end to end. Mirrors the Jira processor (#577 review, item 4).
3113+
*/
3114+
async function screenCommentsOrDrop(
3115+
comments: RenderedComment[],
3116+
issueId: string,
3117+
workspaceId: string,
3118+
): Promise<RenderedComment[]> {
3119+
if (comments.length === 0) return comments;
3120+
if (!attachmentsBedrockClient || !GUARDRAIL_ID || !GUARDRAIL_VERSION) {
3121+
// No guardrail configured — drop unscreened third-party text rather than
3122+
// route it, unscreened, into the agent context.
3123+
logger.warn('Dropping Linear comments: guardrail not configured to screen them', {
3124+
issue_id: issueId,
3125+
linear_workspace_id: workspaceId,
3126+
});
3127+
return [];
3128+
}
3129+
const text = renderCommentSection(comments);
3130+
try {
3131+
const result = await attachmentsBedrockClient.send(new ApplyGuardrailCommand({
3132+
guardrailIdentifier: GUARDRAIL_ID,
3133+
guardrailVersion: GUARDRAIL_VERSION,
3134+
source: 'INPUT',
3135+
content: [{ text: { text } }],
3136+
}));
3137+
if (result.action === 'GUARDRAIL_INTERVENED') {
3138+
logger.warn('Dropping Linear comments: blocked by content policy (task still proceeds)', {
3139+
issue_id: issueId,
3140+
linear_workspace_id: workspaceId,
3141+
});
3142+
return [];
3143+
}
3144+
return comments;
3145+
} catch (err) {
3146+
// Fail-open on a screening outage too — comments are advisory.
3147+
logger.warn('Dropping Linear comments: screening unavailable (task still proceeds)', {
3148+
issue_id: issueId,
3149+
linear_workspace_id: workspaceId,
3150+
error: err instanceof Error ? err.message : String(err),
3151+
});
3152+
return [];
3153+
}
30403154
}
30413155

30423156
/**

cdk/src/handlers/shared/linear-feedback.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import { preservePreviewSuffix } from './iteration-reply';
2121
import { resolveLinearOauthToken } from './linear-oauth-resolver';
2222
import { logger } from './logger';
23+
import { isBotAuthoredComment } from './orchestration-comment-trigger';
2324

2425
/**
2526
* Lambda-side helper for posting comments and reactions onto Linear issues
@@ -104,6 +105,36 @@ query IssueComments($issueId: String!) {
104105
}
105106
`.trim();
106107

108+
/**
109+
* Fetch comments with the author metadata needed to tell a human turn from a
110+
* bot/integration one (ADR-016 pre-hydration — the agent has no Linear MCP to
111+
* read the thread at runtime). ``user`` is the human author (null when a comment
112+
* was posted by an OAuth app / integration); ``botActor`` is present precisely
113+
* for those app/integration comments — so @bgagent's own progress and ack
114+
* comments carry a ``botActor`` and no ``user``. ``createdAt`` orders the thread.
115+
*
116+
* We fetch the first 100 (matching {@link ISSUE_COMMENTS_QUERY}) and sort +
117+
* slice to the most recent human comments CLIENT-SIDE, so the result is
118+
* independent of Linear's connection sort direction. 100 covers every realistic
119+
* issue thread; the rare over-100 issue simply may miss the oldest turns, which
120+
* is acceptable for advisory context.
121+
*/
122+
const RECENT_COMMENTS_QUERY = `
123+
query RecentComments($issueId: String!) {
124+
issue(id: $issueId) {
125+
comments(first: 100) {
126+
nodes {
127+
id
128+
body
129+
createdAt
130+
user { displayName name }
131+
botActor { id }
132+
}
133+
}
134+
}
135+
}
136+
`.trim();
137+
107138
/**
108139
* Post a THREADED REPLY beneath an existing comment (#247 UX.3 ack trail).
109140
* ``parentId`` is the comment being replied to; the reply notifies and reads
@@ -457,6 +488,82 @@ export async function sweepDecompositionNotes(
457488
return deleted;
458489
}
459490

491+
/** A rendered issue comment folded into the task context (mirrors the Jira
492+
* ``RenderedComment`` shape so the two processors read alike). */
493+
export interface RenderedComment {
494+
readonly author: string;
495+
readonly createdAt: string;
496+
readonly markdown: string;
497+
}
498+
499+
/** Default cap on recent human comments folded into the task context. */
500+
const DEFAULT_MAX_RECENT_COMMENTS = 20;
501+
502+
interface RawLinearComment {
503+
readonly id?: string;
504+
readonly body?: string;
505+
readonly createdAt?: string;
506+
readonly user?: { readonly displayName?: string; readonly name?: string } | null;
507+
readonly botActor?: { readonly id?: string } | null;
508+
}
509+
510+
/**
511+
* Fetch the most recent HUMAN-authored comments on an issue, rendered to
512+
* markdown oldest-first, for pre-hydrating the task context (ADR-016 — the agent
513+
* has no Linear MCP to read the thread at runtime). Best-effort / fail-open: any
514+
* failure (token, GraphQL error, malformed body) logs a WARN and returns ``[]``
515+
* so the task still proceeds — comments are advisory context, not a gate.
516+
*
517+
* "Human" excludes app/integration comments two ways (belt and suspenders):
518+
* 1. ``botActor`` present, or ``user`` absent → posted by an OAuth app /
519+
* integration (this is how @bgagent's own comments are marked);
520+
* 2. the body starts with one of the bot's own rendered-comment markers
521+
* ({@link isBotAuthoredComment}) — catches anything mis-attributed to a user.
522+
*/
523+
export async function fetchRecentComments(
524+
ctx: LinearFeedbackContext,
525+
issueId: string,
526+
maxComments: number = DEFAULT_MAX_RECENT_COMMENTS,
527+
): Promise<RenderedComment[]> {
528+
const token = await resolveToken(ctx);
529+
if (!token) return [];
530+
const data = await graphqlData(token, RECENT_COMMENTS_QUERY, { issueId });
531+
const issue = data?.issue as { comments?: { nodes?: RawLinearComment[] } } | undefined;
532+
const nodes = issue?.comments?.nodes ?? [];
533+
534+
const human: RenderedComment[] = [];
535+
for (const node of nodes) {
536+
// Skip app/integration comments (bgagent + other bots): they carry a
537+
// botActor and no human user.
538+
if (node.botActor || !node.user) continue;
539+
const body = (node.body ?? '').trim();
540+
if (!body) continue;
541+
// Belt and suspenders: drop anything that reads as one of our own rendered
542+
// comments even if it slipped through as a "user" comment.
543+
if (isBotAuthoredComment(body)) continue;
544+
human.push({
545+
author: node.user.displayName?.trim() || node.user.name?.trim() || 'Unknown',
546+
createdAt: typeof node.createdAt === 'string' ? node.createdAt : '',
547+
markdown: body,
548+
});
549+
}
550+
551+
// Order oldest-first so the thread reads naturally, then keep the most recent
552+
// ``maxComments`` (sort is client-side — independent of Linear's connection
553+
// sort direction). Comments without a timestamp sort last (treated as newest).
554+
human.sort((a, b) => (a.createdAt || '￿').localeCompare(b.createdAt || '￿'));
555+
const recent = human.length > maxComments ? human.slice(human.length - maxComments) : human;
556+
557+
if (recent.length > 0) {
558+
logger.info('Fetched recent human Linear comments for task context', {
559+
linear_workspace_id: ctx.linearWorkspaceId,
560+
issue_id: issueId,
561+
count: recent.length,
562+
});
563+
}
564+
return recent;
565+
}
566+
460567
/**
461568
* Add an emoji reaction onto a Linear issue. Defaults to ❌ — the failure marker
462569
* the agent uses on the success/failure side. Same result contract as

0 commit comments

Comments
 (0)