From e8c6c5c917b129624cdb01d701f229d00bdb72c0 Mon Sep 17 00:00:00 2001 From: Jon Chery Date: Fri, 29 May 2026 18:15:58 +0000 Subject: [PATCH] =?UTF-8?q?feat(P05):=20ship=20infrastructure=20=E2=80=94?= =?UTF-8?q?=20Gitea=20API=20client,=20release=20notes,=20npm=20publishConf?= =?UTF-8?q?ig,=20ciagent=20projects=20cmd,=20--project=20flag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ---ci--- phase: 5 milestone: v1.0 plan: 05 task: SHIP-01-04 MULTI-01 MULTI-02 status: execute ---/ci--- --- package.json | 14 +++- src/cli/commands.ts | 124 ++++++++++++++++++++++++++++++++ src/cli/index.ts | 14 +++- src/core/gitea.ts | 170 ++++++++++++++++++++++++++++++++++++++++++++ src/core/index.ts | 2 + src/index.ts | 4 +- src/types/config.ts | 14 ++++ 7 files changed, 339 insertions(+), 3 deletions(-) create mode 100644 src/core/gitea.ts diff --git a/package.json b/package.json index 9ce3f30..936b626 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "dev": "ts-node src/cli.ts", "typecheck": "tsc --noEmit", "test": "jest", - "prepublishOnly": "npm run build", + "prepublishOnly": "npm run build && npm test", "install-opencode": "node scripts/postinstall.js" }, "keywords": ["ciagent", "autonomous", "ai", "software-engineering", "agent", "multi-project"], @@ -27,6 +27,18 @@ "engines": { "node": ">=18.0.0" }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://git.cloudinit.dev/continuous-intelligence/ciagent.git" + }, + "homepage": "https://git.cloudinit.dev/continuous-intelligence/ciagent", + "bugs": { + "url": "https://git.cloudinit.dev/continuous-intelligence/ciagent/issues" + }, "dependencies": { "commander": "^12.1.0", "zod": "^3.23.0" diff --git a/src/cli/commands.ts b/src/cli/commands.ts index 220836b..e18ae75 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -15,6 +15,8 @@ import { PipelineState, createInitialPipelineState } from "../types/pipeline.js" import { resolveBackend } from "../backends/index.js"; import { BackendUnavailableError } from "../backends/types.js"; import { getAgent } from "../agents/index.js"; +import { CIAgentFiles } from "../core/ciagent-files.js"; +import { GiteaClient, generateReleaseNotes } from "../core/gitea.js"; import * as fs from "node:fs"; import * as path from "node:path"; import { execSync } from "node:child_process"; @@ -642,6 +644,83 @@ export function createRollbackCommand(): Command { }); } +export function createProjectsCommand(): Command { + const cmd = new Command("projects"); + cmd.description("Manage CIAgent projects in multi-project mode"); + + cmd.command("list") + .description("List all registered projects") + .action(() => { + const projectPath = process.cwd(); + + if (!isCIAgentInitialized(projectPath)) { + console.error("CIAgent project not initialized. Run 'ciagent init' first."); + process.exit(1); + } + + const config = loadConfig(projectPath); + const ciFiles = new CIAgentFiles(projectPath); + const projects = ciFiles.listProjects(); + const activeProject = config.active_project || ciFiles.getActiveProject(); + + if (projects.length === 0) { + console.log("No projects registered."); + console.log("Use 'ciagent projects add ' to add a project."); + return; + } + + console.log("─── CIAgent Projects ───\n"); + for (const project of projects) { + const isActive = project.slug === activeProject; + const marker = isActive ? " *" : ""; + console.log(` ${project.slug} — ${project.name}${marker}`); + } + console.log("\n * = active project"); + }); + + cmd.command("add ") + .description("Add a new project") + .action((slug: string, name: string) => { + const projectPath = process.cwd(); + + if (!isCIAgentInitialized(projectPath)) { + console.error("CIAgent project not initialized. Run 'ciagent init' first."); + process.exit(1); + } + + const ciFiles = new CIAgentFiles(projectPath); + ciFiles.addProject(slug, name); + console.log(`✓ Project added: ${slug} (${name})`); + }); + + cmd.command("set ") + .description("Set the active project") + .action((slug: string) => { + const projectPath = process.cwd(); + + if (!isCIAgentInitialized(projectPath)) { + console.error("CIAgent project not initialized. Run 'ciagent init' first."); + process.exit(1); + } + + const ciFiles = new CIAgentFiles(projectPath); + const projects = ciFiles.listProjects(); + + if (!projects.some((p) => p.slug === slug)) { + console.error(`Project "${slug}" not found. Registered projects: ${projects.map((p) => p.slug).join(", ")}`); + process.exit(1); + } + + ciFiles.setActiveProject(slug); + const config = loadConfig(projectPath); + config.active_project = slug; + saveConfig(projectPath, config); + console.log(`✓ Active project set to: ${slug}`); + }); + + return cmd; +} + export function createShipCommand(): Command { return new Command("ship") .description("Auto-complete phase: verify, security, commit, tag") @@ -713,6 +792,35 @@ export function createShipCommand(): Command { }); console.log(` ✓ Tagged: ${version.tag}`); + if (config.gitea && config.gitea.owner && config.gitea.repo) { + const apiToken = process.env[config.gitea.api_token_env]; + if (apiToken) { + try { + const previousTag = getPreviousTag(projectPath, version.tag); + const releaseNotes = generateReleaseNotes(projectPath, previousTag, version.tag); + + const giteaClient = new GiteaClient({ + baseUrl: config.gitea.base_url, + token: apiToken, + owner: config.gitea.owner, + repo: config.gitea.repo, + }); + + const release = await giteaClient.createRelease({ + tag_name: version.tag, + name: version.tag, + body: releaseNotes, + draft: false, + prerelease: false, + }); + + console.log(` ✓ Release created: ${release.html_url}`); + } catch (giteaErr) { + console.warn(` ⚠ Gitea release failed: ${giteaErr instanceof Error ? giteaErr.message : String(giteaErr)}`); + } + } + } + if (config.git.auto_push) { execSync(`git push origin ${version.tag}`, { cwd: projectPath, stdio: "pipe" }); console.log(` ✓ Pushed tag: ${version.tag}`); @@ -819,4 +927,20 @@ function resolveMergeTarget(projectPath: string, milestoneType: string): string } catch {} return "main"; +} + +function getPreviousTag(projectPath: string, currentTag: string): string | null { + try { + const tags = execSync("git tag -l --sort=-v:refname", { cwd: projectPath, encoding: "utf-8" }) + .split("\n") + .map((t) => t.trim()) + .filter(Boolean); + + const currentIdx = tags.indexOf(currentTag); + if (currentIdx >= 0 && currentIdx + 1 < tags.length) { + return tags[currentIdx + 1]; + } + } catch {} + + return null; } \ No newline at end of file diff --git a/src/cli/index.ts b/src/cli/index.ts index 90c095f..c0d05a1 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -2,6 +2,8 @@ import { Command } from "commander"; import { VERSION } from "../version.js"; +import { CIAgentFiles } from "../core/ciagent-files.js"; +import { isCIAgentInitialized } from "../core/config.js"; import { createInitCommand, createRunCommand, @@ -14,6 +16,7 @@ import { createClarifyCommand, createRollbackCommand, createShipCommand, + createProjectsCommand, } from "./commands.js"; const program = new Command(); @@ -22,6 +25,14 @@ program .name("ciagent") .description("CIAgent — Continuous Intelligence: autonomous AI-driven software engineering harness") .version(VERSION) + .option("--project ", "Specify which project to operate on") + .hook("preAction", () => { + const opts = program.opts(); + if (opts.project && isCIAgentInitialized(process.cwd())) { + const ciFiles = new CIAgentFiles(process.cwd()); + ciFiles.setProjectSlug(opts.project); + } + }) .addCommand(createInitCommand()) .addCommand(createRunCommand()) .addCommand(createQuickCommand()) @@ -32,6 +43,7 @@ program .addCommand(createAuditCommand()) .addCommand(createClarifyCommand()) .addCommand(createRollbackCommand()) - .addCommand(createShipCommand()); + .addCommand(createShipCommand()) + .addCommand(createProjectsCommand()); program.parse(); \ No newline at end of file diff --git a/src/core/gitea.ts b/src/core/gitea.ts new file mode 100644 index 0000000..a3dc01f --- /dev/null +++ b/src/core/gitea.ts @@ -0,0 +1,170 @@ +import { execSync } from "node:child_process"; + +export interface GiteaReleaseConfig { + baseUrl: string; + token: string; + owner: string; + repo: string; +} + +export interface GiteaRelease { + id: number; + tag_name: string; + name: string; + body: string; + url: string; + html_url: string; + draft: boolean; + prerelease: boolean; +} + +export class GiteaClient { + private config: GiteaReleaseConfig; + + constructor(config: GiteaReleaseConfig) { + this.config = config; + } + + async createRelease(params: { + tag_name: string; + name: string; + body: string; + draft?: boolean; + prerelease?: boolean; + }): Promise { + const url = `${this.config.baseUrl}/api/v1/repos/${this.config.owner}/${this.config.repo}/releases`; + const response = await fetch(url, { + method: "POST", + headers: { + "Authorization": `token ${this.config.token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + tag_name: params.tag_name, + name: params.name, + body: params.body, + draft: params.draft ?? false, + prerelease: params.prerelease ?? false, + }), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Gitea API error: ${response.status} ${text}`); + } + + return response.json() as Promise; + } + + async listReleases(): Promise { + const url = `${this.config.baseUrl}/api/v1/repos/${this.config.owner}/${this.config.repo}/releases`; + const response = await fetch(url, { + method: "GET", + headers: { + "Authorization": `token ${this.config.token}`, + }, + }); + + if (!response.ok) { + throw new Error(`Gitea API error: ${response.status}`); + } + + return response.json() as Promise; + } + + async getReleaseByTag(tag: string): Promise { + const url = `${this.config.baseUrl}/api/v1/repos/${this.config.owner}/${this.config.repo}/releases/tags/${tag}`; + const response = await fetch(url, { + method: "GET", + headers: { + "Authorization": `token ${this.config.token}`, + }, + }); + + if (response.status === 404) { + return null; + } + + if (!response.ok) { + throw new Error(`Gitea API error: ${response.status}`); + } + + return response.json() as Promise; + } +} + +export function generateReleaseNotes(projectPath: string, fromTag: string | null, toTag: string): string { + let gitLogCmd: string; + if (fromTag) { + gitLogCmd = `git log ${fromTag}..${toTag} --oneline`; + } else { + gitLogCmd = `git log ${toTag} --oneline`; + } + + let logOutput: string; + try { + logOutput = execSync(gitLogCmd, { cwd: projectPath, encoding: "utf-8" }).trim(); + } catch { + return `## What's Changed\n\nNo commits found between ${fromTag || "beginning"} and ${toTag}.\n`; + } + + if (!logOutput) { + return `## What's Changed\n\nNo commits found between ${fromTag || "beginning"} and ${toTag}.\n`; + } + + const lines = logOutput.split("\n").filter(Boolean); + + const featCommits: string[] = []; + const fixCommits: string[] = []; + const testCommits: string[] = []; + const otherCommits: string[] = []; + + for (const line of lines) { + const subject = line.replace(/^[a-f0-9]+\s+/, ""); + if (/^feat/i.test(subject)) { + featCommits.push(subject); + } else if (/^fix/i.test(subject)) { + fixCommits.push(subject); + } else if (/^test/i.test(subject)) { + testCommits.push(subject); + } else { + otherCommits.push(subject); + } + } + + const sections: string[] = []; + + if (featCommits.length > 0) { + sections.push("### New Features\n"); + for (const c of featCommits) { + sections.push(`- ${c}`); + } + sections.push(""); + } + + if (fixCommits.length > 0) { + sections.push("### Bug Fixes\n"); + for (const c of fixCommits) { + sections.push(`- ${c}`); + } + sections.push(""); + } + + if (testCommits.length > 0) { + sections.push("### Tests\n"); + for (const c of testCommits) { + sections.push(`- ${c}`); + } + sections.push(""); + } + + if (otherCommits.length > 0) { + sections.push("### Other Changes\n"); + for (const c of otherCommits) { + sections.push(`- ${c}`); + } + sections.push(""); + } + + return `## What's Changed\n\n${sections.join("\n")}`; +} \ No newline at end of file diff --git a/src/core/index.ts b/src/core/index.ts index f47fc8e..8231f3f 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -8,5 +8,7 @@ export { GitContext } from "./git-context.js"; export { GitBranch } from "./git-branch.js"; export { CommitBuilder } from "./commit-builder.js"; export { extractCIAgentBlock, parseCIAgentBlock, parseCommitMessage } from "./commit-parser.js"; +export { GiteaClient, generateReleaseNotes } from "./gitea.js"; +export type { GiteaReleaseConfig, GiteaRelease } from "./gitea.js"; export type { CIAgentConfig } from "../types/config.js"; export { DEFAULT_CIAGENT_CONFIG } from "../types/config.js"; \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 1f5b0e8..2b9f785 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ export { GitContext } from "./core/git-context.js"; export { GitBranch } from "./core/git-branch.js"; export { CommitBuilder } from "./core/commit-builder.js"; export { extractCIAgentBlock, parseCIAgentBlock, parseCommitMessage } from "./core/commit-parser.js"; +export { GiteaClient, generateReleaseNotes } from "./core/gitea.js"; export { VerificationPipeline } from "./verification/index.js"; export { StructuralVerification } from "./verification/structural.js"; export { BehavioralVerification } from "./verification/behavioral.js"; @@ -30,7 +31,7 @@ export { OllamaLocalBackend } from "./backends/ollama-local.js"; export { OllamaCloudBackend } from "./backends/ollama-cloud.js"; export { ToolRegistry } from "./backends/tool-registry.js"; -export type { CIAgentConfig, AutonomyLevel, ModelProfile } from "./types/config.js"; +export type { CIAgentConfig, AutonomyLevel, ModelProfile, GiteaConfig } from "./types/config.js"; export type { Decision, DecisionCategory } from "./types/decisions.js"; export type { Escalation, EscalationType } from "./types/escalation.js"; export type { PipelineState, PhaseResult, OrchestratorResult } from "./types/pipeline.js"; @@ -44,5 +45,6 @@ export type { CIAgentMetadata, ParsedCIAgentCommit, CommitType, CommitScope, Com export type { ProjectState, BranchInfo } from "./core/git-context.js"; export type { PhaseBranchInfo, MilestoneBranchInfo, BranchCreateResult, BranchMergeResult } from "./core/git-branch.js"; export type { ProjectMd, RoadmapMd, RequirementsMd, ArchitectureMd } from "./core/ciagent-files.js"; +export type { GiteaReleaseConfig, GiteaRelease } from "./core/gitea.js"; export type { IntelligenceBackend, BackendRequest, BackendResult, BackendConfigSection, BackendUnavailableError, Artifact, TokenUsage } from "./backends/types.js"; export type { ToolDefinition, ToolCall, ToolResult } from "./backends/tool-registry.js"; \ No newline at end of file diff --git a/src/types/config.ts b/src/types/config.ts index 06083b0..83dcb05 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -66,6 +66,13 @@ export interface GitConfig { auto_push: boolean; } +export interface GiteaConfig { + base_url: string; + api_token_env: string; + owner: string; + repo: string; +} + export interface ProjectEntry { slug: string; name: string; @@ -82,6 +89,7 @@ export interface CIAgentConfig { security: SecurityConfig; git: GitConfig; backend: BackendConfigSection; + gitea?: GiteaConfig; } export const DEFAULT_CIAGENT_CONFIG: CIAgentConfig = { @@ -136,4 +144,10 @@ export const DEFAULT_CIAGENT_CONFIG: CIAgentConfig = { }, }, }, + gitea: { + base_url: "https://git.cloudinit.dev", + api_token_env: "GITEA_TOKEN", + owner: "", + repo: "", + }, }; \ No newline at end of file