-
Notifications
You must be signed in to change notification settings - Fork 126
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
github investigate sample #727
Changes from all commits
1bf6d42
13847d5
bb55eb1
9ae2ad6
dcc2010
f584fc6
311831b
0d65534
a3d04f8
70ae328
67a8242
f48627d
d753cba
da9b7ec
dc356b2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,27 @@ | ||
name: genai investigator | ||
on: | ||
workflow_run: | ||
workflows: ["build"] | ||
types: | ||
- completed | ||
permissions: | ||
actions: read | ||
pull-requests: write | ||
jobs: | ||
check_failure: | ||
if: ${{ github.event.workflow_run.conclusion == 'failure' }} | ||
runs-on: ubuntu-latest | ||
steps: | ||
- uses: actions/checkout@v4 | ||
with: | ||
submodules: "recursive" | ||
fetch-depth: 10 | ||
- uses: actions/setup-node@v4 | ||
with: | ||
node-version: "20" | ||
cache: yarn | ||
- run: yarn install --frozen-lockfile | ||
- name: compile | ||
run: yarn compile | ||
- name: genai investigator | ||
run: yarn genai gai -prc --vars "failure_run_id=${{ github.event.workflow_run.id }}" --out-trace $GITHUB_STEP_SUMMARY |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -13,6 +13,7 @@ import { | |
githubCreatePullRequestReviews, | ||
githubUpdatePullRequestDescription, | ||
githubParseEnv, | ||
GithubConnectionInfo, | ||
} from "../../core/src/github" | ||
import { | ||
HTTPS_REGEX, | ||
|
@@ -61,12 +62,14 @@ import { PromptScriptRunOptions } from "../../core/src/server/messages" | |
import { writeFileEdits } from "../../core/src/edits" | ||
import { | ||
azureDevOpsCreateIssueComment, | ||
AzureDevOpsEnv, | ||
azureDevOpsParseEnv, | ||
azureDevOpsUpdatePullRequestDescription, | ||
} from "../../core/src/azuredevops" | ||
import { resolveTokenEncoder } from "../../core/src/encoders" | ||
import { writeFile } from "fs/promises" | ||
import { writeFileSync } from "node:fs" | ||
import { prettifyMarkdown } from "../../core/src/markdown" | ||
|
||
async function setupTraceWriting(trace: MarkdownTrace, filename: string) { | ||
logVerbose(`trace: ${filename}`) | ||
|
@@ -424,37 +427,39 @@ export async function runScript( | |
} | ||
} | ||
|
||
let ghInfo: GithubConnectionInfo = undefined | ||
let adoInfo: AzureDevOpsEnv = undefined | ||
if (pullRequestReviews && result.annotations?.length) { | ||
// github action or repo | ||
const info = await githubParseEnv(process.env) | ||
if (info.repository && info.issue && info.commitSha) { | ||
ghInfo = ghInfo ?? (await githubParseEnv(process.env)) | ||
if (ghInfo.repository && ghInfo.issue && ghInfo.commitSha) { | ||
await githubCreatePullRequestReviews( | ||
script, | ||
info, | ||
ghInfo, | ||
result.annotations | ||
) | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The nullish coalescing operator
|
||
} | ||
|
||
if (pullRequestComment && result.text) { | ||
// github action or repo | ||
const info = await githubParseEnv(process.env) | ||
if (info.repository && info.issue) { | ||
ghInfo = ghInfo ?? (await githubParseEnv(process.env)) | ||
if (ghInfo.repository && ghInfo.issue) { | ||
await githubCreateIssueComment( | ||
script, | ||
info, | ||
result.text, | ||
ghInfo, | ||
prettifyMarkdown(result.text), | ||
typeof pullRequestComment === "string" | ||
? pullRequestComment | ||
: script.id | ||
) | ||
} else { | ||
const adoinfo = await azureDevOpsParseEnv(process.env) | ||
if (adoinfo.collectionUri) { | ||
adoInfo = adoInfo ?? (await azureDevOpsParseEnv(process.env)) | ||
if (adoInfo.collectionUri) { | ||
await azureDevOpsCreateIssueComment( | ||
script, | ||
adoinfo, | ||
result.text, | ||
adoInfo, | ||
prettifyMarkdown(result.text), | ||
typeof pullRequestComment === "string" | ||
? pullRequestComment | ||
: script.id | ||
|
@@ -468,24 +473,24 @@ export async function runScript( | |
|
||
if (pullRequestDescription && result.text) { | ||
// github action or repo | ||
const ghinfo = await githubParseEnv(process.env) | ||
if (ghinfo.repository && ghinfo.issue) { | ||
ghInfo = ghInfo ?? (await githubParseEnv(process.env)) | ||
if (ghInfo.repository && ghInfo.issue) { | ||
await githubUpdatePullRequestDescription( | ||
script, | ||
ghinfo, | ||
result.text, | ||
ghInfo, | ||
prettifyMarkdown(result.text), | ||
typeof pullRequestDescription === "string" | ||
? pullRequestDescription | ||
: script.id | ||
) | ||
} else { | ||
// azure devops pipeline | ||
const adoinfo = await azureDevOpsParseEnv(process.env) | ||
if (adoinfo.collectionUri) { | ||
adoInfo = adoInfo ?? (await azureDevOpsParseEnv(process.env)) | ||
if (adoInfo.collectionUri) { | ||
await azureDevOpsUpdatePullRequestDescription( | ||
script, | ||
adoinfo, | ||
result.text, | ||
adoInfo, | ||
prettifyMarkdown(result.text), | ||
typeof pullRequestDescription === "string" | ||
? pullRequestDescription | ||
: script.id | ||
|
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
|
@@ -33,6 +33,7 @@ | |||||
"cross-fetch": "^4.0.0", | ||||||
"csv-parse": "^5.5.6", | ||||||
"csv-stringify": "^6.5.1", | ||||||
"diff": "^7.0.0", | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this is atest comment
Suggested change
|
||||||
"dotenv": "^16.4.5", | ||||||
"esbuild": "^0.24.0", | ||||||
"fast-xml-parser": "^4.5.0", | ||||||
|
@@ -81,6 +82,7 @@ | |||||
"test": "node --import tsx --test src/**.test.ts" | ||||||
}, | ||||||
"dependencies": { | ||||||
"@types/diff": "^5.2.2", | ||||||
"@types/turndown": "^5.0.5", | ||||||
"turndown": "^7.2.0" | ||||||
} | ||||||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -20,6 +20,7 @@ import { YAMLStringify } from "./yaml" | |
export function renderShellOutput(output: ShellOutput) { | ||
// Destructure the output object to retrieve exitCode, stdout, and stderr. | ||
const { exitCode, stdout, stderr } = output | ||
if (exitCode === 0) return stdout | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You have not handled the case when the exitCode is not 0. This could lead to unexpected behavior if the shell command fails. Please consider adding error handling for this case. 😊
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You are returning stdout when the exitCode is 0, but you are not using stderr in any way. If the shell command generates an error, this information will be lost. Consider including stderr in your output. 😊
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In the case where the exitCode is not 0, there is no explicit return statement. This could lead to undefined behavior. Please ensure all code paths return a value. 😊
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The early return when
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The function
|
||
return ( | ||
[ | ||
// Include exit code in the output only if it's non-zero. | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,225 @@ | ||
/* spellchecker: disable */ | ||
import { Octokit } from "octokit" | ||
import { createPatch } from "diff" | ||
|
||
const workflow = env.vars.workflow || "build.yml" | ||
const ffid = env.vars.failure_run_id | ||
const lsid = env.vars.success_run_id | ||
const branch = | ||
env.vars.branch || | ||
(await host.exec("git branch --show-current")).stdout.trim() | ||
|
||
const octokit = new Octokit({ | ||
auth: process.env.GITHUB_TOKEN, | ||
}) | ||
const { owner, repo } = await getRepoInfo() | ||
|
||
script({ | ||
system: ["system", "system.files"], | ||
cache: "gh-investigator", | ||
}) | ||
|
||
const runs = await listRuns(workflow, branch) | ||
|
||
// first last success | ||
const lsi = lsid | ||
? runs.findIndex(({ id }) => id === lsid) | ||
: runs.findIndex(({ conclusion }) => conclusion === "success") | ||
const ls = runs[lsi] | ||
console.log( | ||
`> last success: ${ls.id}, ${ls.created_at}, ${ls.head_sha}, ${ls.html_url}` | ||
) | ||
const ff = ffid ? runs.find(({ id }) => id === ffid) : runs[lsi - 1] | ||
if (!ff) cancel("failure run not found") | ||
console.log( | ||
`> first failure: ${ff.id}, ${ff.created_at}, ${ff.head_sha}, ${ff.html_url}` | ||
) | ||
if (ff.conclusion !== "failure") cancel("failure run not found") | ||
|
||
const gitDiff = await host.exec( | ||
`git diff ${ls.head_sha} ${ff.head_sha} -- . :!**/genaiscript.d.ts` | ||
) | ||
console.log(`> source diff: ${(gitDiff.stdout.length / 1000) | 0}kb`) | ||
|
||
// download logs | ||
const lsjobs = await downloadRunLog(ls.id) | ||
const lsjob = lsjobs[0] | ||
const lslog = lsjob.text | ||
console.log( | ||
`> last success log: ${(lslog.length / 1000) | 0}kb ${lsjob.logUrl}` | ||
) | ||
const ffjobs = await downloadRunLog(ff.id) | ||
const ffjob = ffjobs[0] | ||
const fflog = ffjob.text | ||
console.log( | ||
`> first failure log: ${(fflog.length / 1000) | 0}kb ${ffjob.logUrl}` | ||
) | ||
|
||
const logDiff = diffJobLogs(lslog, fflog) | ||
console.log(`> log diff: ${(logDiff.length / 1000) | 0}kb`) | ||
|
||
// include difss | ||
def("GIT_DIFF", gitDiff, { | ||
language: "diff", | ||
maxTokens: 10000, | ||
lineNumbers: true, | ||
}) | ||
def("LOG_DIFF", logDiff, { | ||
language: "diff", | ||
maxTokens: 20000, | ||
lineNumbers: false, | ||
}) | ||
$`Your are an expert software engineer and you are able to analyze the logs and find the root cause of the failure. | ||
|
||
- GIT_DIFF contains a diff of 2 run commits | ||
- LOG_DIFF contains a diff of 2 runs in GitHub Action | ||
- The first run is the last successful run and the second run is the first failed run | ||
|
||
Add links to run logs. | ||
|
||
Analyze the diff in LOG_DIFF and provide a summary of the root cause of the failure. | ||
|
||
If you cannot find the root cause, stop. | ||
|
||
Generate a diff with suggested fixes. Use a diff format.` | ||
|
||
writeText( | ||
`## Investigator report | ||
- [run first failure](${ff.html_url}) | ||
- [run last success](${ls.html_url}) | ||
- [commit diff](https://github.com/${owner}/${repo}/compare/${ls.head_sha}...${ff.head_sha}) | ||
|
||
`, | ||
{ assistant: true } | ||
) | ||
|
||
/*----------------------------------------- | ||
|
||
GitHub infra | ||
|
||
-----------------------------------------*/ | ||
async function getRepoInfo() { | ||
const repository = process.env.GITHUB_REPOSITORY | ||
if (repository) { | ||
const [owner, repo] = repository.split("/") | ||
return { owner, repo } | ||
} | ||
const remoteUrl = (await host.exec("git config --get remote.origin.url")) | ||
.stdout | ||
const match = remoteUrl.match(/github\.com\/(?<owner>.+)\/(?<repo>.+)$/) | ||
if (!match) { | ||
throw new Error( | ||
"Could not parse repository information from remote URL" | ||
) | ||
} | ||
const { owner, repo } = match.groups | ||
return { owner, repo } | ||
} | ||
|
||
async function listRuns(workflow_id: string, branch: string) { | ||
// Get the workflow runs for the specified workflow file, filtering for failures | ||
const { | ||
data: { workflow_runs }, | ||
} = await octokit.rest.actions.listWorkflowRuns({ | ||
owner, | ||
repo, | ||
workflow_id, | ||
branch, | ||
per_page: 100, | ||
}) | ||
const runs = workflow_runs.filter( | ||
({ conclusion }) => conclusion !== "skipped" | ||
) | ||
return runs | ||
} | ||
|
||
async function downloadRunLog(run_id: number) { | ||
const res = [] | ||
// Get the jobs for the specified workflow run | ||
const { | ||
data: { jobs }, | ||
} = await octokit.rest.actions.listJobsForWorkflowRun({ | ||
owner, | ||
repo, | ||
run_id, | ||
}) | ||
for (const job of jobs) { | ||
const { url: logUrl } = | ||
await octokit.rest.actions.downloadJobLogsForWorkflowRun({ | ||
owner, | ||
repo, | ||
job_id: job.id, | ||
}) | ||
const { text } = await fetchText(logUrl) | ||
res.push({ ...job, logUrl, text }) | ||
} | ||
return res | ||
} | ||
|
||
function diffJobLogs(firstLog: string, otherLog: string) { | ||
let firsts = parseJobLog(firstLog) | ||
let others = parseJobLog(otherLog) | ||
|
||
// assumption: the list of steps has not changed | ||
const n = Math.min(firsts.length, others.length) | ||
firsts = firsts.slice(0, n) | ||
others = others.slice(0, n) | ||
|
||
// now do a regular diff | ||
const f = firsts | ||
.map((f) => | ||
f.title ? `##[group]${f.title}\n${f.text}\n##[endgroup]` : f.text | ||
) | ||
.join("\n") | ||
const l = others | ||
.map((f) => | ||
f.title ? `##[group]${f.title}\n${f.text}\n##[endgroup]` : f.text | ||
) | ||
.join("\n") | ||
const d = createPatch("log.txt", f, l, undefined, undefined, { | ||
ignoreCase: true, | ||
ignoreWhitespace: true, | ||
}) | ||
return d | ||
} | ||
|
||
function parseJobLog(text: string) { | ||
const lines = cleanLog(text).split(/\r?\n/g) | ||
const groups: { title: string; text: string }[] = [] | ||
let current = groups[0] | ||
for (const line of lines) { | ||
if (line.startsWith("##[group]")) { | ||
current = { title: line.slice("##[group]".length), text: "" } | ||
} else if (line.startsWith("##[endgroup]")) { | ||
if (current) groups.push(current) | ||
current = undefined | ||
} else { | ||
if (!current) current = { title: "", text: "" } | ||
current.text += line + "\n" | ||
} | ||
} | ||
if (current) groups.push(current) | ||
|
||
const ignoreSteps = [ | ||
"Runner Image", | ||
"Fetching the repository", | ||
"Checking out the ref", | ||
"Setting up auth", | ||
"Setting up auth for fetching submodules", | ||
"Getting Git version info", | ||
"Initializing the repository", | ||
"Determining the checkout info", | ||
"Persisting credentials for submodules", | ||
] | ||
return groups.filter(({ title }) => !ignoreSteps.includes(title)) | ||
} | ||
|
||
function cleanLog(text: string) { | ||
return text | ||
.replace( | ||
// timestamps | ||
/^?\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{2,}Z /gm, | ||
"" | ||
) | ||
.replace(/\x1b\[[0-9;]*m/g, "") // ascii colors | ||
} |
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.
Initializing
ghInfo
andadoInfo
toundefined
is unnecessary as variables in TypeScript areundefined
by default. You can simply declare them without initialization. 😊