Skip to content

Commit 8f4354b

Browse files
Merge pull request #16 from japer-technology/copilot/implement-scheduled-events-docs
Phase 5: Scheduled & event-driven skills (schedule, release, deployment_status)
2 parents c0b89b2 + b76345b commit 8f4354b

7 files changed

Lines changed: 401 additions & 39 deletions

File tree

.github-gstack-intelligence/lifecycle/agent.ts

Lines changed: 144 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -117,18 +117,25 @@ const eventName = process.env.GITHUB_EVENT_NAME!;
117117
// Whether this event is a pull_request event (PR review, not an issue).
118118
const isPullRequest = eventName === "pull_request";
119119

120+
// Whether this event is an automated event with no associated issue or PR.
121+
// These events (schedule, release, deployment_status) don't have a target
122+
// issue/PR number for comment posting — results are committed to state only.
123+
const isAutomatedEvent = ["schedule", "release", "deployment_status"].includes(eventName);
124+
120125
// "owner/repo" format — used when calling the GitHub REST API via `gh api`.
121126
const repo = process.env.GITHUB_REPOSITORY!;
122127

123128
// Fall back to "main" if the repository's default branch is not set in the event.
124129
const defaultBranch = event.repository?.default_branch ?? "main";
125130

126131
// The target number is the issue number for issue events, or the PR number for
127-
// pull_request events. In GitHub, PRs are also issues, so the number can be used
128-
// with both issue and PR API endpoints.
132+
// pull_request events. For automated events (schedule, release, deployment_status),
133+
// there is no target number — set to 0.
129134
const targetNumber: number = isPullRequest
130135
? event.pull_request.number
131-
: event.issue.number;
136+
: isAutomatedEvent
137+
? 0
138+
: event.issue?.number ?? 0;
132139

133140
// Read the committed `.pi` defaults and pass them explicitly to the runtime.
134141
// This prevents provider/model drift from host-level config (for example a
@@ -214,16 +221,32 @@ async function main() {
214221
// ── Read title and body from the event payload ───────────────────────────────
215222
// For pull_request events, use the PR title and body.
216223
// For issue events, use the issue title and body from the webhook payload.
224+
// For automated events (schedule, release, deployment_status), title/body
225+
// are derived from the event context (handled by the router's skill prompt).
217226
// GitHub truncates string fields at 65 536 characters in webhook payloads, so
218227
// we fall back to the API only when the body hits that limit.
219228
let title: string;
220229
let body: string;
221230
if (isPullRequest) {
222231
title = event.pull_request.title ?? "";
223232
body = event.pull_request.body ?? "";
233+
} else if (isAutomatedEvent) {
234+
// Automated events don't have an issue/PR — the router builds the prompt
235+
// from the event payload. Title/body are placeholders for context lines.
236+
if (eventName === "release") {
237+
title = `Release: ${event.release?.tag_name ?? "unknown"}`;
238+
body = event.release?.body ?? "";
239+
} else if (eventName === "deployment_status") {
240+
title = `Deployment: ${event.deployment?.environment ?? "unknown"}`;
241+
body = `Status: ${event.deployment_status?.state ?? "unknown"}`;
242+
} else {
243+
// schedule
244+
title = `Scheduled run: ${event.schedule ?? "unknown cron"}`;
245+
body = "";
246+
}
224247
} else {
225-
title = event.issue.title ?? "";
226-
body = event.issue.body ?? "";
248+
title = event.issue?.title ?? "";
249+
body = event.issue?.body ?? "";
227250
if (body.length >= 65536) {
228251
body = await gh("issue", "view", String(targetNumber), "--json", "body", "--jq", ".body");
229252
}
@@ -233,16 +256,16 @@ async function main() {
233256
// Each issue maps to exactly one `pi` session file via `state/issues/<n>.json`.
234257
// If a mapping exists AND the referenced session file is still present, we resume
235258
// the conversation by passing `--session <path>` to `pi`. Otherwise we start fresh.
236-
// For pull_request events, session continuity is not needed — each review is
237-
// a one-shot operation that starts fresh.
259+
// For pull_request and automated events, session continuity is not needed —
260+
// each run is a one-shot operation that starts fresh.
238261
mkdirSync(issuesDir, { recursive: true });
239262
mkdirSync(sessionsDir, { recursive: true });
240263

241264
let mode = "new";
242265
let sessionPath = "";
243-
const mappingFile = isPullRequest ? "" : resolve(issuesDir, `${targetNumber}.json`);
266+
const mappingFile = (isPullRequest || isAutomatedEvent) ? "" : resolve(issuesDir, `${targetNumber}.json`);
244267

245-
if (!isPullRequest && existsSync(mappingFile)) {
268+
if (!isPullRequest && !isAutomatedEvent && existsSync(mappingFile)) {
246269
try {
247270
const mapping = JSON.parse(readFileSync(mappingFile, "utf-8"));
248271
if (existsSync(mapping.sessionPath)) {
@@ -487,8 +510,9 @@ async function main() {
487510
// ── Persist issue → session mapping ─────────────────────────────────────────
488511
// Write (or overwrite) the mapping file so that the next run for this issue
489512
// can locate the correct session transcript and resume the conversation.
490-
// For pull_request events, session mapping is skipped — each review is one-shot.
491-
if (latestSession && !isPullRequest) {
513+
// For pull_request and automated events, session mapping is skipped — each
514+
// run is a one-shot operation.
515+
if (latestSession && !isPullRequest && !isAutomatedEvent) {
492516
writeFileSync(
493517
mappingFile,
494518
JSON.stringify({
@@ -498,7 +522,7 @@ async function main() {
498522
}, null, 2) + "\n"
499523
);
500524
console.log(`Saved mapping: issue #${targetNumber} -> ${latestSession}`);
501-
} else if (!latestSession && !isPullRequest) {
525+
} else if (!latestSession && !isPullRequest && !isAutomatedEvent) {
502526
console.log("Warning: no session file found to map");
503527
}
504528

@@ -557,6 +581,92 @@ async function main() {
557581
console.log(`Persisted ${routeResult.skill} result for #${resultIdentifier}`);
558582
}
559583

584+
// ── Persist retro results ───────────────────────────────────────────────────
585+
// Retro reports are saved as date-stamped JSON in state/results/retro/ so
586+
// subsequent retros can compare trends across weeks.
587+
if (routeResult && routeResult.skill === "retro") {
588+
const resultDir = resolve(stateDir, "results", "retro");
589+
mkdirSync(resultDir, { recursive: true });
590+
591+
const dateStr = new Date().toISOString().slice(0, 10);
592+
const result = {
593+
skill: "retro",
594+
date: dateStr,
595+
timestamp: new Date().toISOString(),
596+
status: "completed",
597+
commit: process.env.GITHUB_SHA ?? null,
598+
};
599+
writeFileSync(
600+
resolve(resultDir, `${dateStr}.json`),
601+
JSON.stringify(result, null, 2) + "\n"
602+
);
603+
console.log(`Persisted retro result for ${dateStr}`);
604+
}
605+
606+
// ── Persist benchmark results ───────────────────────────────────────────────
607+
// Benchmark results are saved as date-stamped JSON in state/benchmarks/history/
608+
// and the baseline file is referenced for comparison.
609+
if (routeResult && routeResult.skill === "benchmark") {
610+
const historyDir = resolve(stateDir, "benchmarks", "history");
611+
mkdirSync(historyDir, { recursive: true });
612+
613+
const dateStr = new Date().toISOString().slice(0, 10);
614+
const result = {
615+
skill: "benchmark",
616+
date: dateStr,
617+
timestamp: new Date().toISOString(),
618+
status: "completed",
619+
commit: process.env.GITHUB_SHA ?? null,
620+
};
621+
writeFileSync(
622+
resolve(historyDir, `${dateStr}.json`),
623+
JSON.stringify(result, null, 2) + "\n"
624+
);
625+
console.log(`Persisted benchmark result for ${dateStr}`);
626+
}
627+
628+
// ── Persist canary results ──────────────────────────────────────────────────
629+
// Canary monitoring results track deployment health checks over time.
630+
if (routeResult && routeResult.skill === "canary") {
631+
const resultDir = resolve(stateDir, "results", "canary");
632+
mkdirSync(resultDir, { recursive: true });
633+
634+
const timestamp = new Date().toISOString();
635+
const result = {
636+
skill: "canary",
637+
url: routeResult.context.url ?? null,
638+
timestamp,
639+
status: "completed",
640+
commit: process.env.GITHUB_SHA ?? null,
641+
};
642+
writeFileSync(
643+
resolve(resultDir, `${timestamp.slice(0, 19).replace(/:/g, "-")}.json`),
644+
JSON.stringify(result, null, 2) + "\n"
645+
);
646+
console.log(`Persisted canary result`);
647+
}
648+
649+
// ── Persist document-release results ────────────────────────────────────────
650+
// Track which releases have been documented by the agent.
651+
if (routeResult && routeResult.skill === "document-release") {
652+
const resultDir = resolve(stateDir, "results", "releases");
653+
mkdirSync(resultDir, { recursive: true });
654+
655+
const tagName = event.release?.tag_name ?? "unknown";
656+
const result = {
657+
skill: "document-release",
658+
tag: tagName,
659+
timestamp: new Date().toISOString(),
660+
status: "completed",
661+
commit: process.env.GITHUB_SHA ?? null,
662+
};
663+
writeFileSync(
664+
resolve(resultDir, `${tagName.replace(/[^a-zA-Z0-9._-]/g, "_")}.json`),
665+
JSON.stringify(result, null, 2) + "\n"
666+
);
667+
console.log(`Persisted document-release result for ${tagName}`);
668+
}
669+
560670
// ── Commit and push state changes ───────────────────────────────────────────
561671
// Stage all changes (session log, mapping JSON, any files the agent edited),
562672
// commit only if the index is dirty, then push with a retry-on-conflict loop.
@@ -569,7 +679,9 @@ async function main() {
569679
// exitCode !== 0 means there are staged changes to commit.
570680
const commitMsg = isPullRequest
571681
? `gstack-intelligence: review PR #${targetNumber}`
572-
: `gstack-intelligence: work on issue #${targetNumber}`;
682+
: isAutomatedEvent
683+
? `gstack-intelligence: ${routeResult?.skill ?? eventName} run`
684+
: `gstack-intelligence: work on issue #${targetNumber}`;
573685
const commitResult = await run(["git", "commit", "-m", commitMsg]);
574686
if (commitResult.exitCode !== 0) {
575687
console.error("git commit failed with exit code", commitResult.exitCode);
@@ -594,22 +706,28 @@ async function main() {
594706
// if the push later fails. The push failure throw (below) must come AFTER the
595707
// comment is posted — otherwise the throw would skip the comment entirely and
596708
// the user would get no reply.
709+
// For automated events (schedule, release, deployment_status), there is no
710+
// target issue/PR to comment on — results are committed to state only.
597711
// For pull_request events, post as a PR comment; for issue events, post as an
598712
// issue comment. Both use the same underlying GitHub API.
599-
const trimmedText = agentText.trim();
600-
let commentBody = trimmedText.length > 0
601-
? trimmedText.slice(0, MAX_COMMENT_LENGTH)
602-
: `✅ The agent ran successfully but did not produce a text response. Check the repository for any file changes that were made.\n\nFor full details, see the [workflow run logs](https://github.com/${repo}/actions).`;
603-
if (!pushSucceeded) {
604-
commentBody += `\n\n---\n⚠️ **Warning:** The agent's session state could not be pushed to the repository. Conversation context may not be preserved for follow-up comments. See the [workflow run logs](https://github.com/${repo}/actions) for details.`;
605-
}
606-
// Append the agent signature as a hidden HTML comment for bot-loop prevention.
607-
// The router checks incoming comments for this signature and skips them.
608-
commentBody += "\n" + AGENT_SIGNATURE;
609-
if (isPullRequest) {
610-
await gh("pr", "comment", String(targetNumber), "--body", commentBody);
713+
if (!isAutomatedEvent) {
714+
const trimmedText = agentText.trim();
715+
let commentBody = trimmedText.length > 0
716+
? trimmedText.slice(0, MAX_COMMENT_LENGTH)
717+
: `✅ The agent ran successfully but did not produce a text response. Check the repository for any file changes that were made.\n\nFor full details, see the [workflow run logs](https://github.com/${repo}/actions).`;
718+
if (!pushSucceeded) {
719+
commentBody += `\n\n---\n⚠️ **Warning:** The agent's session state could not be pushed to the repository. Conversation context may not be preserved for follow-up comments. See the [workflow run logs](https://github.com/${repo}/actions) for details.`;
720+
}
721+
// Append the agent signature as a hidden HTML comment for bot-loop prevention.
722+
// The router checks incoming comments for this signature and skips them.
723+
commentBody += "\n" + AGENT_SIGNATURE;
724+
if (isPullRequest) {
725+
await gh("pr", "comment", String(targetNumber), "--body", commentBody);
726+
} else {
727+
await gh("issue", "comment", String(targetNumber), "--body", commentBody);
728+
}
611729
} else {
612-
await gh("issue", "comment", String(targetNumber), "--body", commentBody);
730+
console.log(`Automated event (${eventName}) — skipping comment posting, results committed to state`);
613731
}
614732

615733
// Throw push failure AFTER the comment has been posted so the user always

0 commit comments

Comments
 (0)