diff --git a/graph-ui/src/browser-ai/BrowserChatDock.test.tsx b/graph-ui/src/browser-ai/BrowserChatDock.test.tsx index a49a21e18..f7b1a2acb 100644 --- a/graph-ui/src/browser-ai/BrowserChatDock.test.tsx +++ b/graph-ui/src/browser-ai/BrowserChatDock.test.tsx @@ -1425,6 +1425,21 @@ describe('grounded automatic explanations (K14, K7)', () => { expect(card().textContent).toContain('Listed from the indexed graph; not generated by the model.'); }); + it('lists the jobs, triggers and actions of a workflow file above the model text (K12)', async () => { + vi.useFakeTimers(); const { props, runtime } = fixture(); + runtime.chat.mockResolvedValueOnce('This workflow greets new contributors on their first pull request.'); + const text = 'name: New contributor message\n\non:\n pull_request_target:\n types: [opened]\n\njobs:\n build:\n name: Hello new contributor\n runs-on: ubuntu-latest\n steps:\n - uses: actions/first-interaction@v1\n'; + await render({ ...props, proactive: true, selectionScope: 'django-demo:explore', readerContext: reader(text, 'file', '.github/workflows/new_contributor_pr.yml') }); + await click('Download & load'); await settle(); + const prompt = runtime.chat.mock.calls[0][0].map(message => message.content).join('\n'); + expect(prompt).toContain('Facts read from the file (counted, not guessed):'); + expect(prompt).toContain('1 job: `build`'); + expect(card().textContent).toContain('1 job: build ("Hello new contributor", runs on ubuntu-latest, 1 step).'); + expect(card().textContent).toContain('Actions used: actions/first-interaction@v1.'); + expect(card().textContent).toContain('This workflow greets new contributors on their first pull request.'); + expect(card().textContent).toContain('Facts read from the file; the text after them is generated by the model.'); + }); + it('explains an Architecture area from readable facts without reading source', async () => { vi.useFakeTimers(); const { props, runtime } = fixture(); const readSource = vi.fn(async () => snippet); diff --git a/graph-ui/src/browser-ai/BrowserChatDock.tsx b/graph-ui/src/browser-ai/BrowserChatDock.tsx index 8b940d852..2a4882e96 100644 --- a/graph-ui/src/browser-ai/BrowserChatDock.tsx +++ b/graph-ui/src/browser-ai/BrowserChatDock.tsx @@ -16,6 +16,7 @@ import ChatMarkdown from './ChatMarkdown'; import AgentSettingsDialog from './AgentSettingsDialog'; import { useChatHistory } from './use-chat-history'; import { chatTopic, followedTopic, missingContextAnswer, topicHistory } from './chat-context'; +import { readerFacts } from './workflow-facts'; import './browser-chat.css'; export type { BrowserChatAttachment, BrowserChatContext, BrowserChatReaderContext, BrowserChatSource } from './chat-model'; @@ -60,9 +61,11 @@ const sizeLabel = (bytes: number): string => bytes >= 1_000_000_000 ? `${(bytes /** Symbol sources read for explanations and questions, newest last. */ const SOURCE_CACHE_SIZE = 32; -/** The listed facts first, then the model's sentence if it stood the check, then who wrote what (K7). */ -function groundedExplanation(summary: readonly string[], sentence: string | undefined, dropped: boolean): string { - const note = sentence ? browserChatText.factsAndSentence : dropped ? browserChatText.sentenceDropped : browserChatText.factsOnly; +/** The listed facts first, then the model's sentence if it stood the check, then who wrote what (K7). + * The facts come from the indexed graph, or for an open workflow from the file itself (K12). */ +function groundedExplanation(summary: readonly string[], sentence: string | undefined, dropped: boolean, from: 'graph' | 'file' = 'graph'): string { + const note = from === 'file' ? sentence ? browserChatText.fileFactsAndSentence : dropped ? browserChatText.fileSentenceDropped : browserChatText.fileFactsOnly + : sentence ? browserChatText.factsAndSentence : dropped ? browserChatText.sentenceDropped : browserChatText.factsOnly; return [summary.map(line => `- ${line}`).join('\n'), ...sentence ? [sentence] : [], `_${note}_`].join('\n\n'); } @@ -351,8 +354,9 @@ export default function BrowserChatDock({ proactiveSelection, selectionScope = " const run = { key: snapshot.key, cancelled: false, settled }; autoRun.current = run; lastAttempt.current = snapshot.key; const valid = () => epoch.current === ticket && !run.cancelled && !stopRequested.current && selectionRef.current?.key === snapshot.key; // Graph selections show their listed facts at once; the model only adds a sentence. - const summary = snapshot.reader ? [] : selectionSummary(snapshot.graph); - const writing = summary.length ? `${summary.map(line => `- ${line}`).join('\n')}\n\n_${sourceTargetOf(snapshot.graph) || carriedSource(snapshot.graph) ? browserChatText.writingSentence : browserChatText.readingFacts}_` : ''; + const summary = snapshot.reader ? readerFacts(snapshot.reader) : selectionSummary(snapshot.graph); + const from = snapshot.reader ? 'file' : 'graph'; + const writing = summary.length ? `${summary.map(line => `- ${line}`).join('\n')}\n\n_${snapshot.reader || sourceTargetOf(snapshot.graph) || carriedSource(snapshot.graph) ? browserChatText.writingSentence : browserChatText.readingFacts}_` : ''; setExplanation({ key: snapshot.key, label: snapshot.label, answer: writing, status: 'generating' }); setPhase('counting'); try { @@ -394,7 +398,7 @@ export default function BrowserChatDock({ proactiveSelection, selectionScope = " // One sentence beside the facts (two for reader code), and none that names what the evidence lacks. const checked = result.status === 'generated' ? explanationSentence(result.markdown, packet, request.map(message => message.content).join('\n')) : {}; if (summary.length || (result.status === 'generated' && checked.dropped)) { - const markdown = summary.length ? groundedExplanation(summary, checked.sentence, checked.dropped !== undefined) : `_${browserChatText.explanationDropped}_`; + const markdown = summary.length ? groundedExplanation(summary, checked.sentence, checked.dropped !== undefined, from) : `_${browserChatText.explanationDropped}_`; const complete: Explanation = { key: snapshot.key, label: snapshot.label, answer: markdown, status: 'complete', mode: 'interpretation', packet, grounded: summary.length > 0, ...shortened && checked.sentence ? { shortened, limit: { inputTokens: autoInput, outputTokens: autoOutput } } : {}, ...snapshot.evidence ? { evidence: snapshot.evidence } : {} }; remember(complete); setExplanation(complete); diff --git a/graph-ui/src/browser-ai/chat-history-cache.ts b/graph-ui/src/browser-ai/chat-history-cache.ts index ac8675668..ed423521a 100644 --- a/graph-ui/src/browser-ai/chat-history-cache.ts +++ b/graph-ui/src/browser-ai/chat-history-cache.ts @@ -79,6 +79,7 @@ function isPreparedEvidence(value: unknown): value is PreparedExplanationContext return isObject(value) && typeof value.label === 'string' && typeof value.fallback === 'string' && typeof value.characterCount === 'number' && Number.isSafeInteger(value.characterCount) && value.characterCount >= 0 && Array.isArray(value.limitations) && value.limitations.every(item => typeof item === 'string') + && (value.fileFacts === undefined || (Array.isArray(value.fileFacts) && value.fileFacts.every(item => typeof item === 'string'))) && Array.isArray(value.evidence) && value.evidence.every(item => isObject(item) && typeof item.id === 'string' && typeof item.text === 'string' && ['code', 'graph'].includes(String(item.source)) && (item.location === undefined || (isObject(item.location) diff --git a/graph-ui/src/browser-ai/chat-model.test.ts b/graph-ui/src/browser-ai/chat-model.test.ts index f51964b12..9fc9f26e7 100644 --- a/graph-ui/src/browser-ai/chat-model.test.ts +++ b/graph-ui/src/browser-ai/chat-model.test.ts @@ -125,5 +125,16 @@ describe('browser chat context', () => { expect(system.content).toContain('never name tools, libraries, languages or values that are not in the file'); expect(user.content).toBe('was kannst du mir über dieses aktuelle File sagen'); }); + + it('gives the model the jobs, triggers and actions of a workflow counted from the file (K12)', () => { + const text = 'name: New contributor message\n\non:\n pull_request_target:\n types: [opened]\n\njobs:\n build:\n name: Hello new contributor\n runs-on: ubuntu-latest\n steps:\n - uses: actions/first-interaction@v1\n'; + const workflow = { project: 'django-demo', path: '.github/workflows/new_contributor_pr.yml', status: 'ready' as const, source: { ...source, kind: 'file' as const, + project: 'django-demo', path: '.github/workflows/new_contributor_pr.yml', text } }; + const [system] = buildChatMessages([], 'Wie viele Jobs gibt es?', undefined, [], workflow); + expect(system.content).toContain('Facts read from the file (counted, not guessed):\n- Workflow name: `New contributor message`.\n- Trigger: `pull_request_target` (types: opened).\n- 1 job: `build`'); + // A marked part of the file is not the whole workflow: nothing is counted from it. + const marked = { ...workflow, source: { ...workflow.source, kind: 'selection' as const } }; + expect(buildChatMessages([], 'Wie viele Jobs gibt es?', undefined, [], marked)[0].content).not.toContain('Facts read from the file'); + }); }); diff --git a/graph-ui/src/browser-ai/chat-model.ts b/graph-ui/src/browser-ai/chat-model.ts index f1e7224b9..418d4a54d 100644 --- a/graph-ui/src/browser-ai/chat-model.ts +++ b/graph-ui/src/browser-ai/chat-model.ts @@ -1,6 +1,8 @@ import type { BrowserChatMessage } from './browser-ai-runtime'; import type { ChatTopic } from './chat-context'; import { fileKind } from './file-kind'; +import { readerFacts } from './workflow-facts'; +import { workflowWords } from './strings'; /** An immutable snapshot, not a live reference to the reader selection. */ export interface BrowserChatAttachment { @@ -81,10 +83,11 @@ export function snapshotReaderContext(context?: BrowserChatReaderContext): Brows ...(currentSource ? { source: currentSource } : {}) }; } -/** What the open file is and what an answer about it may say (K12). */ +/** What the open file is, what is counted from it, and what an answer about it may say (K12). */ function readerRules(context: BrowserChatReaderContext): string { const kind = fileKind(context.source?.path ?? context.path ?? ''); - return `${kind ? `\nThe current file is a ${kind}.` : ''}\nAnswer from the current file and describe what it literally contains. Unless the user asks for it, do not write new code, scripts or commands, ` + const facts = readerFacts(context); + return `${kind ? `\nThe current file is a ${kind}.` : ''}${facts.length ? `\n${workflowWords.heading}\n${facts.map(line => `- ${line}`).join('\n')}` : ''}\nAnswer from the current file and describe what it literally contains. Unless the user asks for it, do not write new code, scripts or commands, ` + 'and never name tools, libraries, languages or values that are not in the file. Do not repeat the file; summarize it. If the file does not answer the question, say so.'; } diff --git a/graph-ui/src/browser-ai/explanation-context.ts b/graph-ui/src/browser-ai/explanation-context.ts index 552c3b15a..77becb5a5 100644 --- a/graph-ui/src/browser-ai/explanation-context.ts +++ b/graph-ui/src/browser-ai/explanation-context.ts @@ -2,6 +2,7 @@ import { snapshotReaderContext, type BrowserChatContext, type BrowserChatReaderC import { fairShares, readGalaxyEvidence, relationshipLine, scopeSentence, selectionSentence, sideLoaded, type GalaxyEvidence } from './galaxy-evidence'; import { relationshipWords } from './strings'; import { architectureFacts } from './architecture-evidence'; +import { readerFacts } from './workflow-facts'; export interface ExplanationEvidence { id: string; @@ -19,6 +20,8 @@ export interface PreparedExplanationContext { characterCount: number; /** Set when the prompt could not name every related symbol the evidence carried: scope size and how many are named. */ capacity?: { nodes: number; edges: number; shown: number }; + /** Facts counted from a whole open workflow file, whatever part of its text fits (K12). */ + fileFacts?: string[]; } const record = (value: unknown): Record | undefined => value !== null && typeof value === 'object' && !Array.isArray(value) @@ -330,7 +333,8 @@ export function prepareExplanationContext(reader?: BrowserChatReaderContext, break; } } + const fileFacts = readerFacts(snapshot); const characterCount = label.length + fallback.length + evidence.reduce((count, item) => count + item.text.length, 0) - + limitations.reduce((count, item) => count + item.length, 0); - return { label, evidence, limitations, fallback, characterCount, ...capacity ? { capacity } : {} }; + + limitations.reduce((count, item) => count + item.length, 0) + fileFacts.reduce((count, item) => count + item.length, 0); + return { label, evidence, limitations, fallback, characterCount, ...capacity ? { capacity } : {}, ...fileFacts.length ? { fileFacts } : {} }; } diff --git a/graph-ui/src/browser-ai/explanation-response.ts b/graph-ui/src/browser-ai/explanation-response.ts index b96f54e9b..bb6f1e22c 100644 --- a/graph-ui/src/browser-ai/explanation-response.ts +++ b/graph-ui/src/browser-ai/explanation-response.ts @@ -1,6 +1,7 @@ import type { BrowserChatMessage } from './browser-ai-controller'; import type { PreparedExplanationContext } from './explanation-context'; import { fileKind } from './file-kind'; +import { workflowWords } from './strings'; export const AUTO_INPUT_TOKENS = 1536; export const AUTO_OUTPUT_TOKENS = 128; @@ -13,6 +14,7 @@ export function formatExplanationEvidence(packet: PreparedExplanationContext): s const kind = (path: string) => { const name = fileKind(path); return name ? `, a ${name}` : ''; }; return [packet.label, ...packet.evidence.map(item => item.source === 'code' ? `Source${item.location ? ` ${item.location.path}:${item.location.startLine}-${item.location.endLine}${kind(item.location.path)}` : ''}:\n${item.text}` : item.text), + ...packet.fileFacts?.length ? [`${workflowWords.heading}\n${packet.fileFacts.map(line => `- ${line}`).join('\n')}`] : [], ...packet.limitations.map(limit => `Limit: ${limit}`)].join('\n\n'); } diff --git a/graph-ui/src/browser-ai/file-kind.ts b/graph-ui/src/browser-ai/file-kind.ts index aaec078b9..1d957b609 100644 --- a/graph-ui/src/browser-ai/file-kind.ts +++ b/graph-ui/src/browser-ai/file-kind.ts @@ -11,12 +11,15 @@ const KINDS: Readonly> = { html: 'HTML document', css: 'CSS stylesheet', sql: 'SQL script', xml: 'XML data, not program code', }; +/** A GitHub Actions workflow, by where GitHub reads it from. */ +export const isGithubWorkflow = (path: string): boolean => /(?:^|\/)\.github\/workflows\/[^/]+\.ya?ml$/i.test(path); + export function fileKind(path: string): string | undefined { const name = path.split('/').pop() ?? path; const template = /^(.+)-tpl$/.exec(name); const extension = (template?.[1] ?? name).split('.').slice(1).pop()?.toLowerCase(); const kind = extension ? KINDS[extension] : undefined; if (!kind) return undefined; - if (/(?:^|\/)\.github\/workflows\/[^/]+\.ya?ml$/i.test(path)) return `GitHub Actions workflow (${kind})`; + if (isGithubWorkflow(path)) return `GitHub Actions workflow (${kind})`; return template ? kind.replace(/ file$/, ' template') : kind; } diff --git a/graph-ui/src/browser-ai/strings.ts b/graph-ui/src/browser-ai/strings.ts index 3e9d9db88..374e3c0a8 100644 --- a/graph-ui/src/browser-ai/strings.ts +++ b/graph-ui/src/browser-ai/strings.ts @@ -148,6 +148,10 @@ export const browserChatText = { factsOnly: 'Listed from the indexed graph; not generated by the model.', sentenceDropped: "Listed from the indexed graph. The model's sentence named something the evidence does not show and was left out.", explanationDropped: "The model's explanation named something the source does not show and was left out. Ask a question about the code instead.", + /** The same for facts read from an open workflow file (K12). */ + fileFactsAndSentence: 'Facts read from the file; the text after them is generated by the model.', + fileFactsOnly: 'Read from the file; not generated by the model.', + fileSentenceDropped: "Read from the file. The model's text named something the file does not show and was left out.", writingSentence: 'Reading the source; the model is adding one sentence…', readingFacts: 'Listing the facts…', /** Above the first question about another file or selection (K17). */ @@ -214,3 +218,16 @@ export const architectureWords = { incoming: (items: readonly string[], total: number) => `Incoming from ${items.join(', ')}${total > items.length ? `; ${count(total - items.length)} more` : ''}.`, outgoing: (items: readonly string[], total: number) => `Outgoing to ${items.join(', ')}${total > items.length ? `; ${count(total - items.length)} more` : ''}.`, }; + +const counted = (value: number, one: string, many: string) => `${value.toLocaleString('en-US')} ${value === 1 ? one : many}`; +/** A GitHub Actions workflow as facts counted from its keys: for the prompt and the explanation card (K12). */ +export const workflowWords = { + heading: 'Facts read from the file (counted, not guessed):', + name: (name: string) => `Workflow name: ${name}.`, + triggers: (items: readonly string[]) => `${items.length === 1 ? 'Trigger' : 'Triggers'}: ${items.join(', ')}.`, + trigger: (event: string, types: readonly string[]) => `${event}${types.length ? ` (types: ${types.join(', ')})` : ''}`, + jobs: (total: number, items: readonly string[]) => `${counted(total, 'job', 'jobs')}: ${items.join('; ')}${total > items.length ? `; +${(total - items.length).toLocaleString('en-US')} more` : ''}.`, + job: (id: string, name: string | undefined, runsOn: string | undefined, steps: number) => + `${id} (${[name ? `"${name}"` : '', runsOn ? `runs on ${runsOn}` : '', counted(steps, 'step', 'steps')].filter(Boolean).join(', ')})`, + uses: (items: readonly string[]) => `Actions used: ${items.join(', ')}.`, +}; diff --git a/graph-ui/src/browser-ai/workflow-facts.test.ts b/graph-ui/src/browser-ai/workflow-facts.test.ts new file mode 100644 index 000000000..763b2d5df --- /dev/null +++ b/graph-ui/src/browser-ai/workflow-facts.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; +import { workflowFacts, workflowFactLines } from './workflow-facts'; + +/** .github/workflows/new_contributor_pr.yml of django-demo, as Explore shows it. */ +const NEW_CONTRIBUTOR = `name: New contributor message + +on: + pull_request_target: + types: [opened] + +permissions: + pull-requests: write + +jobs: + build: + name: Hello new contributor + runs-on: ubuntu-latest + steps: + - uses: actions/first-interaction@v1 + with: + repo-token: \${{ secrets.GITHUB_TOKEN }} + pr-message: | + Hello! Thank you for your contribution + + name: not a key + jobs: not a key either + - uses: not/a-step@v0 + + Welcome aboard! +`; + +describe('facts of a GitHub Actions workflow (K12)', () => { + it('counts the one job, its trigger and the action it uses, not the text of a block value', () => { + expect(workflowFacts(NEW_CONTRIBUTOR)).toEqual({ + name: 'New contributor message', + triggers: [{ event: 'pull_request_target', types: ['opened'] }], + jobs: [{ id: 'build', name: 'Hello new contributor', runsOn: 'ubuntu-latest', steps: 1 }], + uses: ['actions/first-interaction@v1'], + }); + expect(workflowFactLines('.github/workflows/new_contributor_pr.yml', NEW_CONTRIBUTOR)).toEqual([ + 'Workflow name: `New contributor message`.', + 'Trigger: `pull_request_target` (types: opened).', + '1 job: `build` ("Hello new contributor", runs on ubuntu-latest, 1 step).', + 'Actions used: `actions/first-interaction@v1`.', + ]); + }); + + it('reads scalar and list triggers, compact step lists and several jobs', () => { + const text = `name: "CI" +on: [push, pull_request] +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: | + echo one + shell: bash + - name: Test + run: make test + docs: + uses: ./.github/workflows/docs.yml # reusable +`; + expect(workflowFacts(text)).toEqual({ + name: 'CI', + triggers: [{ event: 'push' }, { event: 'pull_request' }], + jobs: [{ id: 'lint', runsOn: 'ubuntu-latest', steps: 3 }, { id: 'docs', steps: 0 }], + uses: ['actions/checkout@v4', './.github/workflows/docs.yml'], + }); + expect(workflowFactLines('.github/workflows/ci.yml', text)).toContain('2 jobs: `lint` (runs on ubuntu-latest, 3 steps); `docs` (0 steps).'); + expect(workflowFacts('on: push\njobs: {}\n')?.triggers).toEqual([{ event: 'push' }]); + }); + + it('says nothing for other files or text that is not a workflow', () => { + expect(workflowFactLines('docker-compose.yml', NEW_CONTRIBUTOR)).toEqual([]); + expect(workflowFactLines('.github/workflows/broken.yml', 'just text')).toEqual([]); + expect(workflowFacts('')).toBeUndefined(); + }); +}); diff --git a/graph-ui/src/browser-ai/workflow-facts.ts b/graph-ui/src/browser-ai/workflow-facts.ts new file mode 100644 index 000000000..f46e94470 --- /dev/null +++ b/graph-ui/src/browser-ai/workflow-facts.ts @@ -0,0 +1,114 @@ +import type { BrowserChatReaderContext } from './chat-model'; +import { isGithubWorkflow } from './file-kind'; +import { workflowWords as words } from './strings'; + +/** A GitHub Actions workflow as facts counted from its keys. The small model answered + * "Es gibt 3 Jobs" for a file with one, and read the trigger type "opened" as a branch + * name (K12): jobs, triggers and actions are counted here, not by the model. No YAML + * library: only the plain block layout workflows are written in. */ +export interface WorkflowFacts { + name?: string; + triggers: { event: string; types?: string[] }[]; + jobs: { id: string; name?: string; runsOn?: string; steps: number }[]; + uses: string[]; +} + +/** One line of the file: where it starts, where its key starts (after "- "), key and value. */ +interface Line { indent: number; keyIndent: number; dash: boolean; key?: string; value: string } + +const unquote = (value: string) => value.replace(/^(["'])(.*)\1$/, '$2'); +/** A plain value without its trailing comment or quotes. */ +const plain = (value: string) => unquote(value.replace(/\s+#.*$/, '').trim()); +/** "[push, pull_request]" or "push"; "{}" and "[]" are empty. */ +const flow = (value: string): string[] => { + const list = /^\[(.*)\]$/.exec(value); + if (!list) return value && value !== '{}' ? [value] : []; + return list[1].split(',').map(plain).filter(Boolean); +}; + +function linesOf(text: string): Line[] { + const lines: Line[] = []; + // The lines of a block value ("key: |") are text, not keys. + let block: number | undefined; + for (const raw of text.split(/\r?\n/)) { + const content = raw.trimStart(); + const indent = raw.length - content.length; + if (block !== undefined) { + if (!content || indent > block) continue; + block = undefined; + } + if (!content || content.startsWith('#')) continue; + const dash = /^-(?:\s|$)/.test(content); + const item = dash ? content.slice(1).trimStart() : content; + const keyIndent = dash ? indent + content.length - item.length : indent; + const match = /^("[^"]*"|'[^']*'|[^\s"'#][^:#]*?)\s*:(?:\s+(.*))?$/.exec(item); + const value = match ? match[2] ?? '' : item; + if (/^[|>][-+0-9]*(?:\s+#.*)?$/.test(value.trim())) block = keyIndent; + lines.push({ indent, keyIndent, dash, key: match ? unquote(match[1].trim()) : undefined, value: plain(value) }); + } + return lines; +} + +/** Every line inside the value of line `at`; a list may start at its key's own indent. */ +function inside(lines: readonly Line[], at: number): number[] { + const parent = lines[at], found: number[] = []; + for (let index = at + 1; index < lines.length; index++) { + const line = lines[index]; + if (line.keyIndent <= parent.keyIndent || (line.dash && line.indent < parent.keyIndent)) break; + found.push(index); + } + return found; +} +/** The direct children of line `at`: the lines inside it at their smallest key indent. */ +function children(lines: readonly Line[], at: number): number[] { + const found = inside(lines, at); + const level = Math.min(...found.map(index => lines[index].keyIndent)); + return found.filter(index => lines[index].keyIndent === level); +} + +export function workflowFacts(text: string): WorkflowFacts | undefined { + const lines = linesOf(text); + if (!lines.length) return undefined; + const level = Math.min(...lines.map(line => line.keyIndent)); + const top = (key: string) => lines.findIndex(line => line.keyIndent === level && !line.dash && line.key === key); + const on = top('on'), jobs = top('jobs'), name = top('name'); + if (on < 0 || jobs < 0) return undefined; + const triggers = lines[on].value ? flow(lines[on].value).map(event => ({ event })) : children(lines, on).flatMap(index => { + const event = lines[index].key ?? lines[index].value; + const at = children(lines, index).find(child => lines[child].key === 'types'); + const types = at === undefined ? [] : lines[at].value ? flow(lines[at].value) : children(lines, at).map(child => lines[child].value).filter(Boolean); + return event ? [{ event, ...types.length ? { types } : {} }] : []; + }); + const jobList = children(lines, jobs).filter(index => lines[index].key).map(index => { + const own = children(lines, index); + const field = (key: string) => { const at = own.find(child => lines[child].key === key); return at === undefined ? undefined : lines[at].value || undefined; }; + const stepsAt = own.find(child => lines[child].key === 'steps'); + const items = stepsAt === undefined ? [] : inside(lines, stepsAt).filter(child => lines[child].dash); + const first = Math.min(...items.map(child => lines[child].indent)); + const jobName = field('name'), runsOn = field('runs-on'); + return { id: lines[index].key!, ...jobName ? { name: jobName } : {}, ...runsOn ? { runsOn } : {}, steps: items.filter(child => lines[child].indent === first).length }; + }); + const uses = [...new Set(inside(lines, jobs).filter(index => lines[index].key === 'uses' && lines[index].value).map(index => lines[index].value))]; + return { ...name >= 0 && lines[name].value ? { name: lines[name].value } : {}, triggers, jobs: jobList, uses }; +} + +const quote = (value: string) => `\`${value.replace(/`/g, "'").slice(0, 120)}\``; +const LISTED = 8; + +/** The facts of a workflow file as sentences; nothing for other files. */ +export function workflowFactLines(path: string, text: string): string[] { + const facts = isGithubWorkflow(path) ? workflowFacts(text) : undefined; + if (!facts) return []; + const lines: string[] = []; + if (facts.name) lines.push(words.name(quote(facts.name))); + if (facts.triggers.length) lines.push(words.triggers(facts.triggers.slice(0, LISTED).map(item => words.trigger(quote(item.event), item.types ?? [])))); + lines.push(words.jobs(facts.jobs.length, facts.jobs.slice(0, LISTED).map(job => words.job(quote(job.id), job.name?.slice(0, 120), job.runsOn?.slice(0, 60), job.steps)))); + if (facts.uses.length) lines.push(words.uses(facts.uses.slice(0, LISTED).map(quote))); + return lines; +} + +/** Only a whole open file is counted: a marked part or a cut excerpt is not the workflow. */ +export function readerFacts(reader: BrowserChatReaderContext | undefined): string[] { + const source = reader?.source; + return source?.kind === 'file' && !source.partial ? workflowFactLines(source.path, source.text) : []; +}