Skip to content

Commit 1cd33d9

Browse files
committed
Sync auto-advance-ball.yml from .github repo
1 parent 69ec0a0 commit 1cd33d9

File tree

1 file changed

+346
-0
lines changed

1 file changed

+346
-0
lines changed
Lines changed: 346 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,346 @@
1+
name: "Automation: Advance the Ball (6h)"
2+
3+
on:
4+
schedule:
5+
- cron: "0 */6 * * *"
6+
workflow_dispatch:
7+
inputs:
8+
llm_provider:
9+
description: "LLM provider"
10+
required: false
11+
default: ""
12+
type: choice
13+
options:
14+
- ""
15+
- openai
16+
- gemini
17+
- anthropic
18+
llm_model:
19+
description: "Model name (provider-specific)"
20+
required: false
21+
default: ""
22+
type: string
23+
24+
permissions:
25+
contents: read
26+
issues: write
27+
28+
concurrency:
29+
group: advance-ball-${{ github.repository }}
30+
cancel-in-progress: true
31+
32+
jobs:
33+
advance:
34+
runs-on: ubuntu-latest
35+
timeout-minutes: 30
36+
steps:
37+
- name: Checkout
38+
uses: actions/checkout@v4
39+
with:
40+
fetch-depth: 0
41+
42+
- name: Gather repo context
43+
shell: bash
44+
run: |
45+
set -euo pipefail
46+
47+
{
48+
echo "Repository: ${GITHUB_REPOSITORY}"
49+
echo "Run URL: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
50+
echo "Ref: ${GITHUB_REF_NAME}"
51+
echo "SHA: ${GITHUB_SHA}"
52+
echo ""
53+
echo "Top-level files/directories:"
54+
ls -la
55+
echo ""
56+
echo "Recent commits (last 20):"
57+
git log --oneline -20 2>/dev/null || true
58+
echo ""
59+
echo "Recent changed files (best-effort):"
60+
git diff --name-only HEAD~20..HEAD 2>/dev/null | head -n 200 || true
61+
echo ""
62+
echo "Recent open PRs/issues pointers:"
63+
echo "- See: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/pulls"
64+
echo "- See: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/issues"
65+
} > /tmp/automation_context.txt
66+
67+
if [ -f "AUTOMATION.txt" ]; then
68+
cat "AUTOMATION.txt" > /tmp/automation_guidance.txt
69+
else
70+
: > /tmp/automation_guidance.txt
71+
fi
72+
73+
- name: Generate direction and write/update issue
74+
uses: actions/github-script@v7
75+
env:
76+
LLM_PROVIDER: ${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.llm_provider) || vars.AUTOMATION_LLM_PROVIDER || vars.LLM_PROVIDER || 'openai' }}
77+
LLM_MODEL: ${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.llm_model) || vars.AUTOMATION_LLM_MODEL || vars.LLM_MODEL || 'gpt-5.2' }}
78+
OPENAI_BASE_URL: ${{ vars.OPENAI_BASE_URL || 'https://api.openai.com/v1' }}
79+
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
80+
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
81+
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
82+
with:
83+
github-token: ${{ secrets.GITHUB_TOKEN }}
84+
script: |
85+
const fs = require("fs");
86+
87+
const owner = context.repo.owner;
88+
const repo = context.repo.repo;
89+
90+
const provider = (process.env.LLM_PROVIDER || "openai").trim().toLowerCase();
91+
const model = (process.env.LLM_MODEL || "").trim();
92+
if (!model) {
93+
core.setFailed("LLM model is required (set vars.AUTOMATION_LLM_MODEL or workflow_dispatch input llm_model).");
94+
return;
95+
}
96+
97+
const contextText = fs.readFileSync("/tmp/automation_context.txt", "utf8");
98+
const guidanceText = fs.readFileSync("/tmp/automation_guidance.txt", "utf8").trim();
99+
100+
const marker = "<!-- advance-ball -->";
101+
const now = new Date().toISOString();
102+
103+
const defaultInstruction = [
104+
"Analyze this repository and propose a concrete direction.",
105+
"If there is no AUTOMATION.txt guidance, decide a direction and proceed by producing an actionable plan.",
106+
"Document your direction, assumptions, risks, and next steps.",
107+
"Prefer small, safe changes that increase reliability and reduce toil.",
108+
"Include suggested tests and a short checklist for verifying progress.",
109+
].join(" ");
110+
111+
const systemPrompt = [
112+
"You are an expert software engineer and technical lead.",
113+
"You are producing an automation direction update for a repository.",
114+
"Be specific, actionable, and concise.",
115+
"Avoid hand-wavy advice; prefer concrete steps and file paths.",
116+
].join(" ");
117+
118+
const userPromptParts = [];
119+
userPromptParts.push(`Repository: ${owner}/${repo}`);
120+
userPromptParts.push(`Timestamp: ${now}`);
121+
userPromptParts.push("");
122+
userPromptParts.push("Repository context:");
123+
userPromptParts.push(contextText);
124+
125+
if (guidanceText) {
126+
userPromptParts.push("");
127+
userPromptParts.push("AUTOMATION.txt guidance (highest priority):");
128+
userPromptParts.push(guidanceText.length > 6000 ? guidanceText.slice(0, 6000) + "\n...(truncated)..." : guidanceText);
129+
} else {
130+
userPromptParts.push("");
131+
userPromptParts.push("No AUTOMATION.txt was found. Use this default instruction:");
132+
userPromptParts.push(defaultInstruction);
133+
}
134+
135+
userPromptParts.push("");
136+
userPromptParts.push("Output format:");
137+
userPromptParts.push("- Summary");
138+
userPromptParts.push("- Direction (what and why)");
139+
userPromptParts.push("- Plan (next 1-3 steps)");
140+
userPromptParts.push("- Risks/unknowns");
141+
userPromptParts.push("- Suggested tests");
142+
143+
const userPrompt = userPromptParts.join("\n");
144+
145+
async function callOpenAI({ apiKey, baseUrl, model, messages }) {
146+
if (!apiKey) throw new Error("OPENAI_API_KEY is not set.");
147+
const url = `${baseUrl.replace(/\\/$/, "")}/chat/completions`;
148+
const payload = { model, messages };
149+
150+
const isGpt5ish = /gpt-?5/i.test(model) || /^o\\d/i.test(model) || /^o1/i.test(model);
151+
if (isGpt5ish) {
152+
payload.max_completion_tokens = 2048;
153+
} else {
154+
payload.max_tokens = 2048;
155+
payload.temperature = 0.2;
156+
}
157+
158+
const resp = await fetch(url, {
159+
method: "POST",
160+
headers: {
161+
"Authorization": `Bearer ${apiKey}`,
162+
"Content-Type": "application/json",
163+
},
164+
body: JSON.stringify(payload),
165+
});
166+
167+
if (!resp.ok) {
168+
const text = await resp.text();
169+
throw new Error(`OpenAI API error (${resp.status}): ${text}`);
170+
}
171+
172+
const data = await resp.json();
173+
const content = data?.choices?.[0]?.message?.content;
174+
if (!content) throw new Error("OpenAI API returned no content.");
175+
return content;
176+
}
177+
178+
async function callGemini({ apiKey, model, prompt }) {
179+
if (!apiKey) throw new Error("GEMINI_API_KEY is not set.");
180+
const geminiModel = model || "gemini-1.5-pro";
181+
const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(geminiModel)}:generateContent?key=${encodeURIComponent(apiKey)}`;
182+
const payload = {
183+
contents: [{ role: "user", parts: [{ text: prompt }] }],
184+
generationConfig: { temperature: 0.2, maxOutputTokens: 2048 },
185+
};
186+
187+
const resp = await fetch(url, {
188+
method: "POST",
189+
headers: { "Content-Type": "application/json" },
190+
body: JSON.stringify(payload),
191+
});
192+
193+
if (!resp.ok) {
194+
const text = await resp.text();
195+
throw new Error(`Gemini API error (${resp.status}): ${text}`);
196+
}
197+
198+
const data = await resp.json();
199+
const parts = data?.candidates?.[0]?.content?.parts || [];
200+
const text = parts.map(p => p.text || "").join("").trim();
201+
if (!text) throw new Error("Gemini API returned no content.");
202+
return text;
203+
}
204+
205+
async function callAnthropic({ apiKey, model, system, prompt }) {
206+
if (!apiKey) throw new Error("ANTHROPIC_API_KEY is not set.");
207+
const anthropicModel = model || "claude-3-5-sonnet-latest";
208+
const url = "https://api.anthropic.com/v1/messages";
209+
const payload = {
210+
model: anthropicModel,
211+
max_tokens: 2048,
212+
temperature: 0.2,
213+
system,
214+
messages: [{ role: "user", content: prompt }],
215+
};
216+
217+
const resp = await fetch(url, {
218+
method: "POST",
219+
headers: {
220+
"x-api-key": apiKey,
221+
"anthropic-version": "2023-06-01",
222+
"content-type": "application/json",
223+
},
224+
body: JSON.stringify(payload),
225+
});
226+
227+
if (!resp.ok) {
228+
const text = await resp.text();
229+
throw new Error(`Anthropic API error (${resp.status}): ${text}`);
230+
}
231+
232+
const data = await resp.json();
233+
const text = (data?.content || []).map(p => p.text || "").join("").trim();
234+
if (!text) throw new Error("Anthropic API returned no content.");
235+
return text;
236+
}
237+
238+
let direction = "";
239+
try {
240+
if (provider === "openai") {
241+
direction = await callOpenAI({
242+
apiKey: process.env.OPENAI_API_KEY,
243+
baseUrl: process.env.OPENAI_BASE_URL,
244+
model,
245+
messages: [
246+
{ role: "system", content: systemPrompt },
247+
{ role: "user", content: userPrompt },
248+
],
249+
});
250+
} else if (provider === "gemini") {
251+
direction = await callGemini({
252+
apiKey: process.env.GEMINI_API_KEY,
253+
model,
254+
prompt: `${systemPrompt}\n\n${userPrompt}`,
255+
});
256+
} else if (provider === "anthropic") {
257+
direction = await callAnthropic({
258+
apiKey: process.env.ANTHROPIC_API_KEY,
259+
model,
260+
system: systemPrompt,
261+
prompt: userPrompt,
262+
});
263+
} else {
264+
throw new Error(`Unsupported provider: ${provider}`);
265+
}
266+
} catch (e) {
267+
core.setFailed(e.message || String(e));
268+
return;
269+
}
270+
271+
async function ensureLabel(name, color, description) {
272+
try {
273+
await github.rest.issues.getLabel({ owner, repo, name });
274+
return;
275+
} catch (e) {
276+
// Not found -> create
277+
}
278+
await github.rest.issues.createLabel({
279+
owner,
280+
repo,
281+
name,
282+
color,
283+
description,
284+
});
285+
}
286+
287+
const labelName = "automation";
288+
try {
289+
await ensureLabel(labelName, "5319E7", "Automation-generated direction and planning");
290+
} catch (e) {
291+
core.info(`Could not ensure label '${labelName}': ${e.message || e}`);
292+
}
293+
294+
const issueTitle = "Automation: Direction";
295+
const newBody = [
296+
marker,
297+
`Last generated: ${now}`,
298+
`Provider: ${provider}`,
299+
`Model: ${model}`,
300+
"",
301+
direction,
302+
].join("\n");
303+
304+
// Find existing open issue with our marker
305+
let existingIssueNumber = null;
306+
try {
307+
const search = await github.rest.search.issuesAndPullRequests({
308+
q: `repo:${owner}/${repo} is:issue is:open in:body \"${marker}\"`,
309+
per_page: 10,
310+
});
311+
const first = (search.data.items || [])[0];
312+
if (first && typeof first.number === "number") existingIssueNumber = first.number;
313+
} catch (e) {
314+
// Search can be delayed; fall back to listing recent issues
315+
}
316+
317+
if (!existingIssueNumber) {
318+
const recent = await github.rest.issues.listForRepo({
319+
owner,
320+
repo,
321+
state: "open",
322+
per_page: 50,
323+
});
324+
const found = (recent.data || []).find(i => typeof i.body === "string" && i.body.includes(marker));
325+
if (found) existingIssueNumber = found.number;
326+
}
327+
328+
if (existingIssueNumber) {
329+
await github.rest.issues.update({
330+
owner,
331+
repo,
332+
issue_number: existingIssueNumber,
333+
title: issueTitle,
334+
body: newBody,
335+
});
336+
core.info(`Updated issue #${existingIssueNumber}`);
337+
} else {
338+
const created = await github.rest.issues.create({
339+
owner,
340+
repo,
341+
title: issueTitle,
342+
body: newBody,
343+
labels: [labelName],
344+
});
345+
core.info(`Created issue #${created.data.number}`);
346+
}

0 commit comments

Comments
 (0)