Skip to content
Merged
93 changes: 68 additions & 25 deletions examples/linear-shipper/agent.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,35 @@
import { handler } from '@agentworkforce/runtime';
import {
draftFile,
encodeSegment,
handler,
readJsonFile,
resolveMountRoot,
writeJsonFile,
type IntegrationClientOptions,
type WorkforceCtx
} from '@agentworkforce/runtime';

type LinearIssueEvent = {
issue?: { id?: string; identifier?: string; title?: string; url?: string };
};

function inputDefault(ctx: Parameters<Parameters<typeof handler>[0]>[0], name: string): string {
interface LinearIssueFile {
id?: string;
identifier?: string;
title?: string;
description?: string;
url?: string;
[key: string]: unknown;
}

function vfsClient(): IntegrationClientOptions {
return { relayfileMountRoot: resolveMountRoot({}) };
}

function inputDefault(ctx: WorkforceCtx, name: string): string {
// Mirror `resolvePersonaInputs` precedence (packages/persona-kit/src/inputs.ts):
// explicit env var (spec.env ?? input name) wins over the runtime-resolved
// value, which in turn wins over the static spec default.
//
// NOTE: the raw spec lives at `ctx.persona.inputSpecs` (Record<string, PersonaInputSpec>);
// `ctx.persona.inputs` is the already-resolved Record<string, string>. The earlier
// version of this helper read .env / .default off `inputs` directly, which only
// worked because the type was looser before the WorkforceCtx readonly tightening.
const spec = ctx.persona.inputSpecs?.[name];
const envName = spec?.env ?? name;
const fromEnv = process.env[envName];
Expand All @@ -37,8 +54,6 @@ function safeRepoDirName(value: string): string {

export default handler(async (ctx, event) => {
if (event.source !== 'linear' || event.type !== 'issue.created') return;
if (!ctx.linear) throw new Error('linear-shipper requires the linear integration');
if (!ctx.github) throw new Error('linear-shipper requires the github integration');

const payload =
typeof event.payload === 'object' && event.payload !== null
Expand All @@ -48,31 +63,59 @@ export default handler(async (ctx, event) => {
const issueId = issueRef?.id ?? issueRef?.identifier;
if (!issueId) throw new Error('Linear event is missing an issue id');

const issue = await ctx.linear.getIssue(issueId);
const client = vfsClient();
const issue = await readJsonFile<LinearIssueFile>(
client,
'linear',
'getIssue',
`/linear/issues/${encodeSegment(issueId)}.json`
);

const repoUrl = inputDefault(ctx, 'REPO_URL');
const owner = inputDefault(ctx, 'GITHUB_OWNER');
const repo = safeRepoDirName(inputDefault(ctx, 'GITHUB_REPO'));
const repoDir = `${ctx.sandbox.cwd}/${repo}`;

await ctx.sandbox.exec(`git clone ${shellQuote(repoUrl)} ${shellQuote(repoDir)}`);
const result = await ctx.harness.run({
prompt: `Implement this Linear issue. Create the smallest reviewable change and include verification notes.\n\nTitle: ${issue.title}\n\n${issue.description ?? ''}`,
prompt: `Implement this Linear issue. Create the smallest reviewable change and include verification notes.\n\nTitle: ${issue.title ?? ''}\n\n${issue.description ?? ''}`,
cwd: repoDir
});

// TODO(human): createPr is not in the published GithubClient contract yet.
const created = await ctx.github.createIssue({
owner,
repo,
title: `Draft PR needed: ${issue.title}`,
body: [
`Linear issue: ${issue.url ?? issueId}`,
'',
'The harness produced an implementation attempt, but GithubClient.createPr is not exposed yet.',
'',
result.output
].join('\n')
});
// No createPullRequest writeback path yet — fall back to a placeholder issue
// so the workflow stays observable end-to-end.
const created = await writeJsonFile(
client,
'github',
'createIssue',
`/github/repos/${encodeSegment(owner)}/${encodeSegment(repo)}/issues/${draftFile('create issue')}`,
{
title: `Draft PR needed: ${issue.title ?? issueId}`,
body: [
`Linear issue: ${issue.url ?? issueId}`,
'',
'Implementation attempt captured below; the github createPullRequest writeback is not exposed yet.',
'',
result.output
].join('\n')
}
);

await ctx.linear.comment(issueId, `Implementation attempt captured in GitHub issue: ${created.url}`);
// Only post a back-link comment when writeback returned a real receipt —
// surfacing the in-mount draft path as if it were a clickable issue URL
// would be misleading.
const issueUrl = created.receipt?.url;
if (!issueUrl) {
ctx.log('warn', 'linear-shipper.github-issue.no-receipt', { draftPath: created.path });
return;
}
await writeJsonFile(
client,
'linear',
'comment',
`/linear/issues/${encodeSegment(issueId)}/comments/${draftFile('comment')}`,
{
body: `Implementation attempt captured in GitHub issue: ${issueUrl}`
}
);
});
51 changes: 39 additions & 12 deletions examples/notion-essay-pr/agent.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import {
draftFile,
encodeSegment,
handler,
resolveMountRoot,
writeJsonFile,
type IntegrationClientOptions,
type WorkforceCtx,
type WorkforceProviderEvent
} from '@agentworkforce/runtime';
Expand All @@ -20,6 +25,10 @@ interface RepoTarget {
repo: string;
}

function vfsClient(): IntegrationClientOptions {
return { relayfileMountRoot: resolveMountRoot({}) };
}

export default handler(async (ctx, event) => {
if (event.source !== 'notion' || event.type !== 'page.created') {
ctx.log('debug', 'notion-essay-pr.ignored', {
Expand All @@ -32,7 +41,6 @@ export default handler(async (ctx, event) => {
});

async function handleNotionPageCreated(ctx: WorkforceCtx, event: WorkforceProviderEvent): Promise<void> {
if (!ctx.github) throw new Error('notion-essay-pr requires the github integration');
const payload = readPayload(event.payload);
const pageId = pageIdFrom(payload);
const pageTitle = pageTitleFrom(payload, event.summary?.title);
Expand All @@ -50,18 +58,37 @@ async function handleNotionPageCreated(ctx: WorkforceCtx, event: WorkforceProvid

await ctx.files.write(outputPath, essay);
const branch = `essay/${safeBranchSegment(pageId)}`;
const pr = await ctx.github.createPullRequest({
...repoTarget,
title: `Essay: ${pageTitle}`,
body: `Drafted from Notion page ${pageId}.\n\nOutput: ${outputPath}`,
head: branch,
base: 'main',
files: {
[`output/${safeFileSegment(pageId)}.md`]: essay
const pr = await writeJsonFile(
vfsClient(),
'github',
'createPullRequest',
`/github/repos/${encodeSegment(repoTarget.owner)}/${encodeSegment(repoTarget.repo)}/pulls/${draftFile('create pr')}`,
{
title: `Essay: ${pageTitle}`,
body: `Drafted from Notion page ${pageId}.\n\nOutput: ${outputPath}`,
head: branch,
base: 'main',
files: {
[`output/${safeFileSegment(pageId)}.md`]: essay
}
}
});
);

// Only treat the PR as "opened" when the writeback worker returned a
// receipt with a real GitHub URL — `pr.path` is the in-mount draft and
// would mislead anyone who saw it in memory or logs.
const prUrl = pr.receipt?.url;
if (!prUrl) {
ctx.log('warn', 'notion-essay-pr.pr-pending', {
pageId,
pageTitle,
outputPath,
draftPath: pr.path
});
return;
}

await ctx.memory.save(`Notion essay PR opened for ${pageTitle}: ${pr.url}`, {
await ctx.memory.save(`Notion essay PR opened for ${pageTitle}: ${prUrl}`, {
scope: 'workspace',
tags: ['notion-essay-pr', `page:${pageId}`]
});
Expand All @@ -70,7 +97,7 @@ async function handleNotionPageCreated(ctx: WorkforceCtx, event: WorkforceProvid
pageId,
pageTitle,
outputPath,
prUrl: pr.url
prUrl
});
}

Expand Down
Loading
Loading