Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,39 +1,35 @@
import type { FlueClient } from '@flue/client';
import { anthropic, github, githubBody } from '@flue/client/proxies';
import type { FlueContext, FlueSession } from '@flue/sdk/client';
import { defineCommand } from '@flue/sdk/node';
import * as v from 'valibot';
import {
GITHUB_TOKEN_BASE,
type IssueDetails,
type RepoLabel,
addGitHubLabels,
fetchIssueDetails,
fetchRepoLabels,
postGitHubComment,
removeGitHubLabel,
} from './github.ts';

export const proxies = {
anthropic: anthropic(),
github: github({
policy: {
base: 'allow-read',
allow: [
// Allow read-only access to the GraphQL endpoint
{ method: 'POST', path: '/graphql', body: githubBody.graphql() },
// Allow git clone, fetch, and push over smart HTTP transport
{ method: 'GET', path: '/*/*/info/refs' },
{ method: 'POST', path: '/*/*/git-upload-pack' },
{ method: 'POST', path: '/*/*/git-receive-pack' },
],
},
}),
};
} from '../lib/github.ts';

// CLI-only agent: no HTTP trigger. Invoked from GitHub Actions via `flue run issue-triage`.
export const triggers = {};

// Define commands that are allowed as pass-through to the local GH Actions container.
const bgproc = defineCommand('bgproc');
const agentBrowser = defineCommand('agent-browser');
const node = defineCommand('node');
const pnpm = defineCommand('pnpm');
const gh = defineCommand('gh', { env: { GH_TOKEN: GITHUB_TOKEN_BASE } });
const git = defineCommand('git');
const gitWithAuth = defineCommand('git', { env: { GH_TOKEN: GITHUB_TOKEN_BASE } });

function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}

async function shouldRetriage(flue: FlueClient, issue: IssueDetails): Promise<'yes' | 'no'> {
return flue.prompt(
async function shouldRetriage(session: FlueSession, issue: IssueDetails): Promise<'yes' | 'no'> {
return session.prompt(
`You are reviewing a GitHub issue conversation to decide whether a triage re-run is warranted.

## Issue
Expand Down Expand Up @@ -65,7 +61,7 @@ Return only "yes" or "no" inside the ---RESULT_START--- / ---RESULT_END--- block
}

async function selectTriageLabels(
flue: FlueClient,
session: FlueSession,
{
comment,
priorityLabels,
Expand All @@ -75,7 +71,7 @@ async function selectTriageLabels(
const priorityLabelNames = priorityLabels.map((l) => l.name);
const packageLabelNames = packageLabels.map((l) => l.name);

const labelResult = await flue.prompt(
const labelResult = await session.prompt(
`Label the following GitHub issue based on the triage report that was already posted.

Select labels for this issue from the lists below based on the triage report. Select exactly one priority label (the report's **Priority** section is a strong hint) and 0-3 package labels based on where the issue lives in the monorepo and how it manifests.
Expand Down Expand Up @@ -114,7 +110,7 @@ ${comment}
}

async function runTriagePipeline(
flue: FlueClient,
session: FlueSession,
issueNumber: number,
issueDetails: IssueDetails,
): Promise<{
Expand All @@ -127,7 +123,7 @@ async function runTriagePipeline(
fixed: boolean;
commitMessage: string | null;
}> {
const reproduceResult = await flue.skill('triage/reproduce.md', {
const reproduceResult = await session.skill('triage/reproduce.md', {
args: { issueNumber, issueDetails },
result: v.object({
reproducible: v.pipe(
Expand Down Expand Up @@ -155,7 +151,7 @@ async function runTriagePipeline(
};
}

const diagnoseResult = await flue.skill('triage/diagnose.md', {
const diagnoseResult = await session.skill('triage/diagnose.md', {
args: { issueDetails },
result: v.object({
confidence: v.pipe(
Expand All @@ -164,7 +160,7 @@ async function runTriagePipeline(
),
}),
});
const verifyResult = await flue.skill('triage/verify.md', {
const verifyResult = await session.skill('triage/verify.md', {
args: { issueDetails },
result: v.object({
verdict: v.pipe(
Expand All @@ -190,7 +186,7 @@ async function runTriagePipeline(
};
}

const fixResult = await flue.skill('triage/fix.md', {
const fixResult = await session.skill('triage/fix.md', {
args: { issueDetails },
result: v.object({
fixed: v.pipe(
Expand All @@ -216,29 +212,31 @@ async function runTriagePipeline(
};
}

export const args = v.object({
issueNumber: v.number(),
});

export default async function triage(
flue: FlueClient,
{ issueNumber }: v.InferOutput<typeof args>,
) {
export default async function ({ init, payload }: FlueContext) {
const issueNumber = payload.issueNumber as number;
const branch = `flue/fix-${issueNumber}`;

// Initialize the session.
const session = await init({
sandbox: 'local',
model: 'anthropic/claude-opus-4-6',
commands: [gh, bgproc, agentBrowser, git, node, pnpm],
});

const issueDetails = await fetchIssueDetails(issueNumber);

// If there are prior comments, this is a re-triage. Check whether new
// actionable information has been provided before running the full pipeline.
const hasExistingConversation = issueDetails.comments.length > 0;
if (hasExistingConversation) {
const shouldRetriageResult = await shouldRetriage(flue, issueDetails);
const shouldRetriageResult = await shouldRetriage(session, issueDetails);
if (shouldRetriageResult === 'no') {
return { skipped: true, reason: 'No new actionable information' };
}
}

// Run the triage pipeline: reproduce → diagnose → verify → fix
const triageResult = await runTriagePipeline(flue, issueNumber, issueDetails);
const triageResult = await runTriagePipeline(session, issueNumber, issueDetails);
let isPushed = false;

// Push the fix branch if there are meaningful changes (fix, failing test, etc.).
Expand All @@ -247,19 +245,19 @@ export default async function triage(
// - create a PR from that branch entirely in the GH UI
// - ignore it completely
{
const diff = await flue.shell('git diff main --stat');
const diff = await session.shell('git diff main --stat');
if (diff.stdout.trim()) {
const status = await flue.shell('git status --porcelain');
const status = await session.shell('git status --porcelain');
if (status.stdout.trim()) {
await flue.shell('git add -A');
await session.shell('git add -A');
const defaultMessage = triageResult.fixed
? 'fix(auto-triage): automated fix'
: 'test(auto-triage): failing test and investigation notes';
await flue.shell(
await session.shell(
`git commit -m ${JSON.stringify(triageResult.commitMessage ?? defaultMessage)}`,
);
}
const pushResult = await flue.shell(`git push -f origin ${branch}`);
const pushResult = await session.shell(`git push -f origin ${branch}`, {commands: [gitWithAuth]});
console.info('push result:', pushResult);
isPushed = pushResult.exitCode === 0;
}
Expand All @@ -272,7 +270,7 @@ export default async function triage(
assert(packageLabels.length > 0, 'no package labels found');

const branchName = isPushed ? branch : null;
const comment = await flue.skill('triage/comment.md', {
const comment = await session.skill('triage/comment.md', {
args: { branchName, priorityLabels, issueDetails },
result: v.pipe(
v.string(),
Expand All @@ -286,7 +284,7 @@ export default async function triage(

if (triageResult.reproducible) {
await removeGitHubLabel(issueNumber, 'needs triage');
const selectedLabels = await selectTriageLabels(flue, {
const selectedLabels = await selectTriageLabels(session, {
comment,
priorityLabels,
packageLabels,
Expand Down
33 changes: 23 additions & 10 deletions .flue/workflows/issue-triage/github.ts → .flue/lib/github.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import * as v from 'valibot';

const REPO = 'withastro/astro';
export const REPO = process.env.GITHUB_REPOSITORY || 'withastro/astro';
export const GITHUB_TOKEN_BASE = process.env.GITHUB_TOKEN;

function headers(): Record<string, string> {
const token = process.env.FREDKBOT_GITHUB_TOKEN || process.env.GITHUB_TOKEN;
if (!token) throw new Error('token is not set');
// Intentionally not exported, GITHUB_TOKEN_BASE should be enough anywhere else.
const GITHUB_TOKEN_PRIVILEGED = process.env.FREDKBOT_GITHUB_TOKEN;

function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}

function headers(token: string): Record<string, string> {
return {
Authorization: `token ${token}`,
'Content-Type': 'application/json',
Expand Down Expand Up @@ -40,10 +46,13 @@ export const repoLabelSchema = v.object({
export type RepoLabel = v.InferOutput<typeof repoLabelSchema>;

export async function fetchIssueDetails(issueNumber: number): Promise<IssueDetails> {
assert(GITHUB_TOKEN_BASE, `GITHUB_TOKEN env token is required.`);
const [issueRes, commentsRes] = await Promise.all([
fetch(`https://api.github.com/repos/${REPO}/issues/${issueNumber}`, { headers: headers() }),
fetch(`https://api.github.com/repos/${REPO}/issues/${issueNumber}`, {
headers: headers(GITHUB_TOKEN_BASE),
}),
fetch(`https://api.github.com/repos/${REPO}/issues/${issueNumber}/comments?per_page=100`, {
headers: headers(),
headers: headers(GITHUB_TOKEN_BASE),
}),
]);

Expand Down Expand Up @@ -84,14 +93,15 @@ export async function fetchRepoLabels(): Promise<{
priorityLabels: RepoLabel[];
packageLabels: RepoLabel[];
}> {
assert(GITHUB_TOKEN_BASE, `GITHUB_TOKEN env token is required.`);
const allLabels: RepoLabel[] = [];
let page = 1;

// Paginate through all labels (100 per page)
while (true) {
const res = await fetch(
`https://api.github.com/repos/${REPO}/labels?per_page=100&page=${page}`,
{ headers: headers() },
{ headers: headers(GITHUB_TOKEN_BASE) },
);
if (!res.ok) {
throw new Error(`Failed to fetch labels (HTTP ${res.status}): ${await res.text()}`);
Expand All @@ -109,9 +119,10 @@ export async function fetchRepoLabels(): Promise<{
}

export async function postGitHubComment(issueNumber: number, body: string): Promise<void> {
assert(GITHUB_TOKEN_PRIVILEGED, `FREDKBOT_GITHUB_TOKEN token is required.`);
const res = await fetch(`https://api.github.com/repos/${REPO}/issues/${issueNumber}/comments`, {
method: 'POST',
headers: headers(),
headers: headers(GITHUB_TOKEN_PRIVILEGED),
body: JSON.stringify({ body }),
});
if (!res.ok) {
Expand All @@ -120,9 +131,10 @@ export async function postGitHubComment(issueNumber: number, body: string): Prom
}

export async function addGitHubLabels(issueNumber: number, labels: string[]): Promise<void> {
assert(GITHUB_TOKEN_PRIVILEGED, `FREDKBOT_GITHUB_TOKEN token is required.`);
const res = await fetch(`https://api.github.com/repos/${REPO}/issues/${issueNumber}/labels`, {
method: 'POST',
headers: headers(),
headers: headers(GITHUB_TOKEN_PRIVILEGED),
body: JSON.stringify({ labels }),
});
if (!res.ok) {
Expand All @@ -131,11 +143,12 @@ export async function addGitHubLabels(issueNumber: number, labels: string[]): Pr
}

export async function removeGitHubLabel(issueNumber: number, label: string): Promise<void> {
assert(GITHUB_TOKEN_PRIVILEGED, `FREDKBOT_GITHUB_TOKEN token is required.`);
const res = await fetch(
`https://api.github.com/repos/${REPO}/issues/${issueNumber}/labels/${encodeURIComponent(label)}`,
{
method: 'DELETE',
headers: headers(),
headers: headers(GITHUB_TOKEN_PRIVILEGED),
},
);
if (!res.ok && res.status !== 404) {
Expand Down
4 changes: 0 additions & 4 deletions .flue/sandbox/AGENTS.md

This file was deleted.

73 changes: 0 additions & 73 deletions .flue/sandbox/Dockerfile

This file was deleted.

Loading
Loading