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
2 changes: 1 addition & 1 deletion .synced-from
Original file line number Diff line number Diff line change
@@ -1 +1 @@
58a1034ccd38510d510ccf2f1e91e84769227c48
18783d4bd1229332317b902efd00a57f67934346
22 changes: 20 additions & 2 deletions actions/ci/npmrcGuard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
* pnpmfiles off. what's left for a PR is its own package-manager config, so this refuses any that
* could hand the token to another host or run code during the install:
* - auth settings or `${…}` env interpolation in .npmrc (`//evil/:_authToken=${NODE_AUTH_TOKEN}`)
* - an `@touchlesscode` registry other than GitHub Packages
* - an `@touchlesscode` registry other than GitHub Packages; pnpm-workspace.yaml names GitHub Packages only in
* `registries:` as `'@touchlesscode': https://npm.pkg.github.com`, the .npmrc line's twin (exo since EXO-8588)
* - npm/pnpm settings that run code or swap the config files (node-options, script-shell, pnpmfile, …)
* - a `packageManager` that isn't a plain `<name>@<version>` (pnpm downloads and runs that version)
*
Expand Down Expand Up @@ -33,6 +34,22 @@ export const npmrcProblemsOf = (text: string, path = ".npmrc") =>
].filter((problem): problem is string => problem !== undefined)
})

const touchlesscodeRegistry = /^\s+(['"]?)@touchlesscode\1\s*:\s*(['"]?)https:\/\/npm\.pkg\.github\.com\/?\2\s*(?:#.*)?$/

/** problems with registries in pnpm-workspace.yaml, by line: GitHub Packages only as the `@touchlesscode` scope's, and that scope nowhere else */
export const workspaceRegistryProblemsOf = (text: string, path = "pnpm-workspace.yaml") => {
let section = ""
return text.split(/\r?\n/).flatMap((line, index) => {
if (/^[^\s#]/.test(line)) section = line.replace(/:.*$/, "").trim()
const where = `${path}:${index + 1}`
const allowed = section === "registries" && touchlesscodeRegistry.test(line)
return [
/npm\.pkg\.github\.com/.test(line) && !allowed ? `${where} names GitHub Packages other than as registries' '@touchlesscode': https://npm.pkg.github.com` : undefined,
section === "registries" && /@touchlesscode/.test(line) && !allowed ? `${where} points @touchlesscode at a registry other than https://npm.pkg.github.com` : undefined,
].filter((problem): problem is string => problem !== undefined)
})
}

/** every reason the packages token must not reach this repo's install; empty when it's safe */
export const packagesTokenProblemsOf = (repo: RepoSnapshot) => {
const npmrc = repo.readText(".npmrc")
Expand All @@ -46,7 +63,8 @@ export const packagesTokenProblemsOf = (repo: RepoSnapshot) => {
return [
...(npmrc ? npmrcProblemsOf(npmrc) : []),
/^\s*(?:pnpmfile|globalPnpmfile|npmrcAuthFile|configDependencies)\s*:/m.test(workspace) ? "pnpm-workspace.yaml sets a pnpmfile, auth file or config dependencies, which can run code during install" : undefined,
/npm\.pkg\.github\.com|_authToken|\$\{/.test(workspace) ? "pnpm-workspace.yaml carries registry auth or env interpolation" : undefined,
/_authToken|\$\{/.test(workspace) ? "pnpm-workspace.yaml carries registry auth or env interpolation" : undefined,
...workspaceRegistryProblemsOf(workspace),
packageManager !== undefined && (typeof packageManager !== "string" || !packageManagerPattern.test(packageManager)) ? `package.json packageManager "${String(packageManager)}" isn't <name>@<version>` : undefined,
].filter((problem): problem is string => problem !== undefined)
}
Expand Down
34 changes: 28 additions & 6 deletions actions/second-look/action.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
name: Second Look (beta)
description: "Second Look's steps outside the testers, in the private second-look repo's dispatched run. prepare: fetch the PR, open its check, wait for its previews and bundle its files and diff for the testers (never runs PR code). synthesize: the main reviewer, a read-only agent on the base branch that reads the PR's Jira ticket and linked designs, the code around the change and the repo's docs, reviews the diff, folds in both testers' reports, leaves its findings as inline comments on the diff, posts one sticky summary comment and closes the check. abandon: closes the check and tells the PR when the run ended without a review."
description: "Second Look's steps outside the testers, in the private second-look repo's dispatched run. prepare: fetch the PR, open its check, wait for its previews and bundle its files and diff for the testers (never runs PR code). synthesize: the main reviewer, a read-only agent on the base branch that reads the PR's Jira ticket and linked designs, the code around the change and the repo's docs, reviews the diff, folds in both testers' reports, leaves its findings as inline comments on the diff, posts one sticky summary comment and closes the check. abandon: closes the check and tells the PR when the run ended without a review. answer: replies to one person's question on the PR (a comment that mentions clanker-in-chief, or a reply in one of its threads) from a read-only session on the base branch, with the diff, its last summary and the ticket."
inputs:
step:
description: "prepare, synthesize or abandon"
description: "prepare, synthesize, abandon or answer"
required: true
github-token:
description: "a Touchless Bot App token for the PR's repo only. prepare: contents + pull-requests read; synthesize: contents read, issues + pull-requests write (posting falls back to it when post-token is blank, and it re-requests clanker-in-chief after the inline review); abandon: issues + pull-requests write, for the didn't-finish comment review when post-token is blank"
Expand All @@ -11,7 +11,7 @@ inputs:
description: "a Touchless Bot App token with checks write on the PR's repo, minted on its own so a missing Checks permission only means no check. blank: no check"
required: false
post-token:
description: "synthesize, abandon: clanker-in-chief's classic PAT (repo), so the review, its inline comments and the didn't-finish reply come from that user. without it the App posts as touchless-bot[bot]"
description: "synthesize, abandon, answer: clanker-in-chief's classic PAT (repo), so the review, its inline comments, answers and the didn't-finish reply come from that user. without it the App posts as touchless-bot[bot]"
required: false
repository:
description: "the PR's repo, owner/name"
Expand All @@ -31,6 +31,12 @@ inputs:
check-run-id:
description: "synthesize, abandon: the check prepare opened"
required: false
comment-id:
description: "answer: the id of the comment that asked"
required: false
comment-kind:
description: "answer: issue (the PR's conversation) or review (an inline thread)"
required: false
result:
description: "abandon: the reviewer job's result (cancelled, failure, skipped)"
required: false
Expand All @@ -49,7 +55,7 @@ inputs:
openai-model:
required: false
budget-usd:
description: "the Claude reviewer's spend cap per run (default 3)"
description: "the Claude reviewer's spend cap per run (default 3; 1 for an answer)"
required: false
jira-url:
required: false
Expand Down Expand Up @@ -85,6 +91,7 @@ runs:
SECOND_LOOK_REPOSITORY: ${{ inputs.repository }}
SECOND_LOOK_NUMBER: ${{ inputs.number }}
SECOND_LOOK_HEAD_SHA: ${{ inputs.head-sha }}
SECOND_LOOK_ANSWERING: ${{ inputs.step == 'answer' }}
SECOND_LOOK_EVENT_PATH: ${{ runner.temp }}/second-look-bundle/event.json
- id: check
if: inputs.step == 'prepare' && inputs.checks-token != ''
Expand Down Expand Up @@ -142,7 +149,7 @@ runs:
name: second-look-bundle
path: ${{ runner.temp }}/second-look-bundle
retention-days: 1
- if: inputs.step == 'synthesize'
- if: inputs.step == 'synthesize' || inputs.step == 'answer'
shell: bash
run: npm install --global --no-audit --no-fund --loglevel=error @anthropic-ai/claude-code @openai/codex
- if: inputs.step == 'synthesize'
Expand All @@ -152,7 +159,7 @@ runs:
pattern: second-look-report-*
merge-multiple: true
path: ${{ runner.temp }}/second-look-reports
- if: inputs.step == 'synthesize'
- if: inputs.step == 'synthesize' || inputs.step == 'answer'
shell: bash
run: node "$GITHUB_ACTION_PATH/gatherContext.ts"
env:
Expand Down Expand Up @@ -183,6 +190,21 @@ runs:
SECOND_LOOK_REPORTS_DIRECTORY: ${{ runner.temp }}/second-look-reports
SECOND_LOOK_DEFAULT_RULES: ${{ github.action_path }}/rules.md
SECOND_LOOK_TESTING_RULES: ${{ github.action_path }}/testingRules.md
- if: inputs.step == 'answer'
shell: bash
run: node "$GITHUB_ACTION_PATH/answer.ts"
env:
GITHUB_TOKEN: ${{ inputs.github-token }}
SECOND_LOOK_POST_TOKEN: ${{ inputs.post-token }}
SECOND_LOOK_EVENT_PATH: ${{ runner.temp }}/second-look-bundle/event.json
SECOND_LOOK_COMMENT_ID: ${{ inputs.comment-id }}
SECOND_LOOK_COMMENT_KIND: ${{ inputs.comment-kind }}
SECOND_LOOK_CONTEXT_DIRECTORY: ${{ runner.temp }}/second-look-inputs/context
SECOND_LOOK_ANTHROPIC_API_KEY: ${{ inputs.anthropic-api-key }}
SECOND_LOOK_OPENAI_API_KEY: ${{ inputs.openai-api-key }}
SECOND_LOOK_ANTHROPIC_MODEL: ${{ inputs.anthropic-model }}
SECOND_LOOK_OPENAI_MODEL: ${{ inputs.openai-model }}
SECOND_LOOK_BUDGET_USD: ${{ inputs.budget-usd }}
- if: inputs.step == 'abandon' && inputs.checks-token != '' && inputs.check-run-id != ''
shell: bash
run: node "$GITHUB_ACTION_PATH/checkRun.ts" abandon
Expand Down
123 changes: 123 additions & 0 deletions actions/second-look/answer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
#!/usr/bin/env node
/**
* second look, answering one person: a question someone asked @clanker-in-chief on a pr, or a reply to one of its
* inline threads, that the relay dispatched with the comment's id. in a read-only session on the base branch (the
* pr's files at its head copied beside it), with the diff, second look's last summary, the thread or conversation
* and the ticket, it writes a short answer and posts it there as clanker-in-chief: a reply in the thread, or a
* conversation comment quoting the question. when no answer comes back it says so, so the relay's 👀 isn't the last
* word. it never reviews, approves, or runs pr code.
*
* node answer.ts (in the workflow, cwd = the pr's repo checked out at its base, with the head fetched)
*
* env: SECOND_LOOK_POST_TOKEN (clanker-in-chief; falls back to GITHUB_TOKEN), GITHUB_TOKEN (the app: contents read,
* issues + pull requests write on the pr's repo), SECOND_LOOK_EVENT_PATH, SECOND_LOOK_COMMENT_ID,
* SECOND_LOOK_COMMENT_KIND (issue | review), RUNNER_TEMP, SECOND_LOOK_ANTHROPIC_API_KEY and/or SECOND_LOOK_OPENAI_API_KEY,
* optional SECOND_LOOK_ANTHROPIC_MODEL, SECOND_LOOK_OPENAI_MODEL, SECOND_LOOK_BUDGET_USD (default 1),
* SECOND_LOOK_TIMEOUT_MINUTES (default 8), SECOND_LOOK_CONTEXT_DIRECTORY (gatherContext.ts's output).
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
import { homedir } from "node:os"
import { join } from "node:path"
import { answerPromptOf } from "./answerPrompt.ts"
import { answerReplyOf, noAnswerReplyOf } from "./answerReplyOf.ts"
import { childEnvironmentOf } from "./childEnvironmentOf.ts"
import { cliCommandOf, defaultModels } from "./cliCommandOf.ts"
import { commentMarker } from "./commentBodyOf.ts"
import { finalMessageOf } from "./finalMessageOf.ts"
import { cappedDiffOf, gitDiffOf } from "./gitDiffOf.ts"
import { harnessOf } from "./harnessOf.ts"
import { issueCommentsOf } from "./issueCommentsOf.ts"
import { jsonObjectOf } from "./jsonObjectOf.ts"
import { prepareAuth } from "./prepareAuth.ts"
import { questionOf, type QuestionComment, type QuestionKind } from "./questionOf.ts"
import { replyToThread } from "./replyToThread.ts"
import { appLogin, posterLogin } from "./reviewerLogins.ts"
import { reviewerFamilyOf } from "./reviewerFamilyOf.ts"
import { runCli } from "./runCli.ts"
import { answerSchema } from "./schemas.ts"
import { targetOf } from "./targetOf.ts"
import type { Family } from "./types.ts"
import { writeHeadFiles } from "./writeHeadFiles.ts"

const maxPromptDiffChars = 60_000
const maxHeadFiles = 60

const keyOf = (family: Family) => process.env[`SECOND_LOOK_${family.toUpperCase()}_API_KEY`] ?? ""
const modelOf = (family: Family) => process.env[`SECOND_LOOK_${family.toUpperCase()}_MODEL`] || defaultModels[family]

/** every review comment on a pr, oldest first */
const reviewCommentsOf = async ({ token, repository, number }: { token: string; repository: string; number: number }): Promise<QuestionComment[]> => {
const pageOf = async (page: number): Promise<QuestionComment[]> => {
const response = await fetch(`https://api.github.com/repos/${repository}/pulls/${number}/comments?per_page=100&page=${page}`, { headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" } })
if (!response.ok) throw new Error(`listing review comments answered ${response.status}`)
const comments = (await response.json()) as QuestionComment[]
return comments.length === 100 ? [...comments, ...(await pageOf(page + 1))] : comments
}
return pageOf(1)
}

/** the model's answer, or why there isn't one */
const answerOf = async ({ family, prompt, inputs }: { family: Family; prompt: string; inputs: string }): Promise<{ answer?: string; error?: string }> => {
const key = keyOf(family)
if (!key) return { error: "no model key is set up" }
const schemaPath = join(inputs, "answer.schema.json")
const outputPath = join(inputs, `${family}-answer.json`)
writeFileSync(schemaPath, JSON.stringify(answerSchema))
const { keyFile, extraEnvironment } = prepareAuth({ family, key, directory: join(process.env.RUNNER_TEMP ?? "/tmp", "second-look-auth") })
const { command, args } = cliCommandOf({ family, role: "reviewer", model: modelOf(family), filesDirectory: inputs, schemaText: JSON.stringify(answerSchema), schemaPath, outputPath, budgetUsd: Number(process.env.SECOND_LOOK_BUDGET_USD) || 1, keyFile, home: homedir() })
const timeoutMinutes = Number(process.env.SECOND_LOOK_TIMEOUT_MINUTES) || 8
const result = await runCli({ command, args, cwd: process.cwd(), environment: childEnvironmentOf(process.env, extraEnvironment), input: prompt, timeoutMs: timeoutMinutes * 60_000 })
const { text } = finalMessageOf({ family, stdout: result.stdout, outputPath })
if (!text.trim()) return { error: result.timedOut ? `ran out of time after ${timeoutMinutes} minutes` : "the model gave no answer" }
try {
const { answer } = jsonObjectOf(text)
return typeof answer === "string" && answer.trim() ? { answer } : { error: "the model's answer was empty" }
} catch {
return { error: "the model's answer didn't parse" }
}
}

const main = async () => {
const { repository, pull } = targetOf()
const kind: QuestionKind = process.env.SECOND_LOOK_COMMENT_KIND === "review" ? "review" : "issue"
const commentId = Number(process.env.SECOND_LOOK_COMMENT_ID)
if (!Number.isInteger(commentId) || commentId < 1) throw new Error(`"${process.env.SECOND_LOOK_COMMENT_ID}" isn't a comment id`)
const postToken = process.env.SECOND_LOOK_POST_TOKEN || process.env.GITHUB_TOKEN || ""
const appToken = process.env.GITHUB_TOKEN || postToken
const author = process.env.SECOND_LOOK_POST_TOKEN ? posterLogin : appLogin
const conversation = await issueCommentsOf({ token: appToken, repository, number: pull.number })
const question = questionOf({ kind, commentId, comments: kind === "review" ? await reviewCommentsOf({ token: appToken, repository, number: pull.number }) : conversation })
if (!question) throw new Error(`comment ${commentId} isn't on ${repository}#${pull.number}`)

const inputs = join(process.env.RUNNER_TEMP ?? "/tmp", "second-look-inputs")
mkdirSync(inputs, { recursive: true })
const { diff, changedPaths } = gitDiffOf({ base: pull.base.sha, head: pull.head.sha })
const diffPath = join(inputs, "pr.diff")
writeFileSync(diffPath, diff)
const headDirectory = join(inputs, "head")
writeHeadFiles({ head: pull.head.sha, paths: [...(question.path ? [question.path] : []), ...changedPaths].slice(0, maxHeadFiles), directory: headDirectory })
const capped = cappedDiffOf(diff, maxPromptDiffChars)
const summary = conversation.filter((comment) => comment.user?.login === author && (comment.body ?? "").includes(commentMarker)).at(-1)?.body ?? ""
const contextPath = join(process.env.SECOND_LOOK_CONTEXT_DIRECTORY || join(inputs, "context"), "context.md")
const family = reviewerFamilyOf(harnessOf(pull.body), { anthropic: Boolean(keyOf("anthropic")), openai: Boolean(keyOf("openai")) })
const prompt = answerPromptOf({ repository, pull, reviewer: family, question, diff: capped.text, truncated: capped.truncated, diffPath, headDirectory, summary, context: existsSync(contextPath) ? readFileSync(contextPath, "utf8") : "" })

const { answer, error } = await answerOf({ family, prompt, inputs }).catch((failure: Error) => ({ answer: undefined, error: failure.message }))
if (error) console.log(`::warning title=Second Look (beta)::no answer for comment ${commentId}: ${error}`)
const body = answer ? answerReplyOf({ answer, question, reviewer: family }) : noAnswerReplyOf({ question, reason: error ?? "no answer" })
if (question.kind === "review") await replyToThread({ token: postToken, repository, number: pull.number, commentId: question.replyTo, body })
else {
const response = await fetch(`https://api.github.com/repos/${repository}/issues/${pull.number}/comments`, {
method: "POST",
headers: { authorization: `Bearer ${postToken}`, accept: "application/vnd.github+json", "content-type": "application/json" },
body: JSON.stringify({ body }),
})
if (!response.ok) throw new Error(`posting the answer answered ${response.status}`)
}
console.log(`second look: answered @${question.author} on ${repository}#${pull.number} (${question.kind} comment ${commentId})${answer ? "" : `, without an answer: ${error}`}`)
}

if (import.meta.main) main().catch((error: Error) => {
console.error(`second look: ${error.message}`)
process.exit(1)
})
Loading