mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-10-29 07:13:44 +08:00
fix(ui): count the jobs, triggers and actions of an open workflow file
The small model still invented structure in Explore: "Es gibt 3 Jobs" for a workflow with one job, and the trigger type "opened" became a branch name. The jobs, triggers, steps and actions of a whole GitHub Actions workflow are now counted from its keys and given to the model as facts, and the automatic explanation lists them above the model text with a note that they were read from the file. Signed-off-by: Bernhard Jackiewicz <bj@techport.io>
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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.';
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, unknown> | 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 } : {} };
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -11,12 +11,15 @@ const KINDS: Readonly<Record<string, string>> = {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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(', ')}.`,
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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) : [];
|
||||
}
|
||||
Reference in New Issue
Block a user