diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 725dab4..a08dbaf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -206,7 +206,7 @@ jobs: name: ${{ matrix.artifact }} path: ${{ matrix.artifact }} - # Generate AI-powered changelog + # Generate changelog using git-cliff (fast, free, no API keys needed) changelog: needs: [check, bump-version, build] if: | @@ -224,185 +224,135 @@ jobs: - name: Pull latest changes run: git pull origin ${{ github.ref_name }} || true - - name: Get previous tag - id: prev_tag + - name: Install git-cliff run: | - # Get the most recent tag before this release - PREV_TAG=$(git tag -l 'v*' --sort=-v:refname | head -2 | tail -1) - if [ -z "$PREV_TAG" ]; then - # No previous tag, use first commit - PREV_TAG=$(git rev-list --max-parents=0 HEAD) - fi - echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT - echo "Previous tag: $PREV_TAG" + # Install git-cliff v2.11.0 (Rust-based changelog generator) + # Using latest version to address CVE-2024-32650 in rustls dependency + VERSION="2.11.0" + TARBALL="git-cliff-${VERSION}-x86_64-unknown-linux-gnu.tar.gz" + DOWNLOAD_URL="https://github.com/orhun/git-cliff/releases/download/v${VERSION}" - - name: Collect commits - id: commits - run: | - PREV_TAG="${{ steps.prev_tag.outputs.prev_tag }}" - - # Get commits with conventional commit format parsing - echo "## Commits since $PREV_TAG" > commits.md - echo "" >> commits.md - - # Group commits by type - git log "$PREV_TAG"..HEAD --pretty=format:"%s|%h|%an" | while IFS='|' read -r msg hash author; do - echo "$msg|$hash|$author" - done > raw_commits.txt - - # Parse and categorize commits - echo "### โœจ Features" > features.md - echo "" >> features.md - echo "### ๐Ÿ› Bug Fixes" > fixes.md - echo "" >> fixes.md - echo "### ๐Ÿ”’ Security" > security.md - echo "" >> security.md - echo "### ๐Ÿ“š Documentation" > docs.md - echo "" >> docs.md - echo "### ๐Ÿ”ง Maintenance" > chores.md - echo "" >> chores.md - echo "### ๐ŸŽจ Refactoring" > refactor.md - echo "" >> refactor.md - echo "### โšก Performance" > perf.md - echo "" >> perf.md - echo "### ๐Ÿงช Tests" > tests.md - echo "" >> tests.md - echo "### ๐Ÿ“ฆ Other Changes" > other.md - echo "" >> other.md - - while IFS='|' read -r msg hash author; do - # Extract type from conventional commit - if [[ "$msg" =~ ^feat(\(.+\))?:\ (.+) ]]; then - scope="${BASH_REMATCH[1]}" - desc="${BASH_REMATCH[2]}" - echo "- ${desc} (\`${hash}\`) - @${author}" >> features.md - elif [[ "$msg" =~ ^fix(\(.+\))?:\ (.+) ]]; then - scope="${BASH_REMATCH[1]}" - desc="${BASH_REMATCH[2]}" - echo "- ${desc} (\`${hash}\`) - @${author}" >> fixes.md - elif [[ "$msg" =~ ^security(\(.+\))?:\ (.+) ]]; then - scope="${BASH_REMATCH[1]}" - desc="${BASH_REMATCH[2]}" - echo "- ${desc} (\`${hash}\`) - @${author}" >> security.md - elif [[ "$msg" =~ ^docs?(\(.+\))?:\ (.+) ]]; then - scope="${BASH_REMATCH[1]}" - desc="${BASH_REMATCH[2]}" - echo "- ${desc} (\`${hash}\`) - @${author}" >> docs.md - elif [[ "$msg" =~ ^chore(\(.+\))?:\ (.+) ]]; then - scope="${BASH_REMATCH[1]}" - desc="${BASH_REMATCH[2]}" - echo "- ${desc} (\`${hash}\`) - @${author}" >> chores.md - elif [[ "$msg" =~ ^refactor(\(.+\))?:\ (.+) ]]; then - scope="${BASH_REMATCH[1]}" - desc="${BASH_REMATCH[2]}" - echo "- ${desc} (\`${hash}\`) - @${author}" >> refactor.md - elif [[ "$msg" =~ ^perf(\(.+\))?:\ (.+) ]]; then - scope="${BASH_REMATCH[1]}" - desc="${BASH_REMATCH[2]}" - echo "- ${desc} (\`${hash}\`) - @${author}" >> perf.md - elif [[ "$msg" =~ ^test(\(.+\))?:\ (.+) ]]; then - scope="${BASH_REMATCH[1]}" - desc="${BASH_REMATCH[2]}" - echo "- ${desc} (\`${hash}\`) - @${author}" >> tests.md - else - # Other commits - echo "- ${msg} (\`${hash}\`) - @${author}" >> other.md - fi - done < raw_commits.txt + # Download the tarball and checksum + curl -sSfL "${DOWNLOAD_URL}/${TARBALL}" -o "${TARBALL}" + curl -sSfL "${DOWNLOAD_URL}/${TARBALL}.sha512" -o "${TARBALL}.sha512" + + # Verify checksum before installation + echo "Verifying checksum..." + sha512sum -c "${TARBALL}.sha512" + + # Extract and install + tar xzf "${TARBALL}" + sudo mv "git-cliff-${VERSION}/git-cliff" /usr/local/bin/ + + # Cleanup + rm -rf "${TARBALL}" "${TARBALL}.sha512" "git-cliff-${VERSION}" - - name: Generate changelog with AI + git-cliff --version + + - name: Generate changelog id: generate - env: - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} run: | VERSION="${{ needs.check.outputs.version }}" - # Combine categorized commits - { - echo "# Release v${VERSION}" - echo "" - echo "Released on $(date '+%Y-%m-%d')" - echo "" - - # Add non-empty sections - for file in features.md fixes.md security.md docs.md refactor.md perf.md tests.md chores.md other.md; do - if [ -f "$file" ] && [ $(wc -l < "$file") -gt 2 ]; then - cat "$file" - echo "" - fi - done - } > changelog_draft.md - - # If OpenAI API key is available, enhance with AI - if [ -n "$OPENAI_API_KEY" ]; then - echo "Enhancing changelog with AI..." - - # Prepare prompt for AI - COMMITS=$(cat raw_commits.txt | head -50) - - # Call OpenAI API to generate summary - RESPONSE=$(curl -s https://api.openai.com/v1/chat/completions \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer $OPENAI_API_KEY" \ - -d @- < changelog.md - else - echo "AI summary generation failed, using draft changelog" - cp changelog_draft.md changelog.md - fi + # Previous release = newest v* tag strictly older (by version order) + # than the one being released. The new tag does not exist yet on + # workflow_run/workflow_dispatch and does on a tag push; either way it + # is not its own predecessor, and a re-release of an older version never + # picks a newer tag. + PREV_TAG=$( (git tag -l 'v*'; echo "v${VERSION}") | sort -uV | + awk -v target="v${VERSION}" '$0 == target { exit } { prev = $0 } END { print prev }') + if [ -n "$PREV_TAG" ]; then + RANGE="${PREV_TAG}..HEAD" + else + # No previous release: leave RANGE empty to cover the full history + RANGE="" + fi + echo "Generating changelog for ${RANGE:-full history}" + + # Create git-cliff config inline + cat > cliff.toml << 'CLIFF_CONFIG' + [changelog] + header = "" + body = """ + # Release {{ version }} + + Released on {{ timestamp | date(format="%Y-%m-%d") }} + + {% for group, commits in commits | group_by(attribute="group") %} + ### {{ group | upper_first }} + {% for commit in commits %} + - {{ commit.message | split(pat="\n") | first | trim }} (`{{ commit.id | truncate(length=7, end="") }}`) - @{{ commit.author.name }} + {%- endfor %} + {% endfor %} + """ + footer = "" + trim = true + + [git] + conventional_commits = true + filter_unconventional = false + split_commits = false + commit_parsers = [ + { message = "^feat", group = "โœจ Features" }, + { message = "^fix", group = "๐Ÿ› Bug Fixes" }, + { message = "^security", group = "๐Ÿ”’ Security" }, + { message = "^doc", group = "๐Ÿ“š Documentation" }, + { message = "^perf", group = "โšก Performance" }, + { message = "^refactor", group = "๐ŸŽจ Refactoring" }, + { message = "^test", group = "๐Ÿงช Tests" }, + { message = "^chore: bump version", skip = true }, + { message = "^Merge ", skip = true }, + { message = "^chore", group = "๐Ÿ”ง Maintenance" }, + { message = "^ci", group = "๐Ÿ”ง Maintenance" }, + { message = "^build", group = "๐Ÿ”ง Maintenance" }, + { message = ".*", group = "๐Ÿ“ฆ Other" }, + ] + filter_commits = false + tag_pattern = "v[0-9].*" + CLIFF_CONFIG + + # Generate changelog for this release with proper error handling + # Capture exit code to distinguish between failure and empty output + CLIFF_EXIT_CODE=0 + git-cliff --config cliff.toml --tag "v${VERSION}" ${RANGE:+"$RANGE"} > changelog.md 2> cliff.err || CLIFF_EXIT_CODE=$? + cat cliff.err >&2 + + # Check if git-cliff failed (non-zero exit code) + if [ $CLIFF_EXIT_CODE -ne 0 ]; then + echo "git-cliff failed with exit code $CLIFF_EXIT_CODE, using fallback" + { + echo "# Release v${VERSION}" + echo "" + echo "Released on $(date '+%Y-%m-%d')" + echo "" + echo "### ๐Ÿ“ฆ Changes" + echo "" + git log "${RANGE:-HEAD}" --pretty=format:"- %s (\`%h\`) - @%an" + } > changelog.md + # Check if git-cliff succeeded but produced empty output + elif [ ! -s changelog.md ]; then + echo "git-cliff produced empty output (no commits in range), using fallback" + { + echo "# Release v${VERSION}" + echo "" + echo "Released on $(date '+%Y-%m-%d')" + echo "" + echo "### ๐Ÿ“ฆ Changes" + echo "" + git log "${RANGE:-HEAD}" --pretty=format:"- %s (\`%h\`) - @%an" + } > changelog.md else - echo "No OpenAI API key, using standard changelog" - cp changelog_draft.md changelog.md + echo "git-cliff succeeded with valid output" fi # Output changelog for use in release - echo "changelog<> $GITHUB_OUTPUT + DELIM="CHANGELOG_$(openssl rand -hex 8)" + echo "changelog<<${DELIM}" >> $GITHUB_OUTPUT cat changelog.md >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT + echo "${DELIM}" >> $GITHUB_OUTPUT - # Also save as artifact + # Display changelog + echo "=== Generated Changelog ===" cat changelog.md - name: Upload changelog artifact diff --git a/src/mcp/handler.rs b/src/mcp/handler.rs index eacc3f6..12254f0 100644 --- a/src/mcp/handler.rs +++ b/src/mcp/handler.rs @@ -9,6 +9,8 @@ use crate::error::Result; use crate::mcp::protocol::{Tool, ToolResult}; /// Handler for MCP tool calls. +// async_trait expands to boxed futures, which trips clippy::double_must_use on newer stable. +#[allow(clippy::double_must_use)] #[async_trait] pub trait ToolHandler: Send + Sync { /// Get the tool definition. diff --git a/src/mcp/transport.rs b/src/mcp/transport.rs index eca05bd..296f3ed 100644 --- a/src/mcp/transport.rs +++ b/src/mcp/transport.rs @@ -20,6 +20,8 @@ pub enum Message { } /// Transport trait for MCP communication. +// async_trait expands to boxed futures, which trips clippy::double_must_use on newer stable. +#[allow(clippy::double_must_use)] #[async_trait] pub trait Transport: Send + Sync { /// Start the transport, returning channels for messages. diff --git a/src/tools/planning.rs b/src/tools/planning.rs index 9ea98be..a2ba43f 100644 --- a/src/tools/planning.rs +++ b/src/tools/planning.rs @@ -732,7 +732,7 @@ impl ToolHandler for ViewProgressTool { "in_progress": in_progress, "failed": failed, "pending": pending, - "progress_percent": if total > 0 { (completed * 100) / total } else { 0 } + "progress_percent": (completed * 100).checked_div(total).unwrap_or(0) }); Ok(success_result(serde_json::to_string_pretty(&progress)?)) } @@ -974,3 +974,68 @@ impl ToolHandler for RollbackPlanTool { Ok(success_result(serde_json::to_string_pretty(&result)?)) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::mcp::protocol::ContentBlock; + use tempfile::TempDir; + + fn step(id: u32, status: StepStatus) -> Step { + Step { + id, + title: format!("step {id}"), + description: String::new(), + status, + step_type: StepType::Implementation, + dependencies: vec![], + affected_files: vec![], + estimated_duration: None, + actual_duration: None, + completed_at: None, + error: None, + output: None, + requires_approval: false, + approval: None, + rollback: None, + validation: vec![], + } + } + + async fn progress_percent(steps: Vec) -> u64 { + let temp = TempDir::new().unwrap(); + let service = Arc::new(PlanningService::new(temp.path()).await.unwrap()); + let plan = service + .create_plan("plan".to_string(), String::new()) + .await + .unwrap(); + for s in steps { + service.add_step(&plan.id, s).await.unwrap(); + } + + let tool = ViewProgressTool::new(service); + let args = HashMap::from([("plan_id".to_string(), Value::String(plan.id))]); + let result = tool.execute(args).await.unwrap(); + let ContentBlock::Text { text } = &result.content[0] else { + panic!("expected text content"); + }; + let json: Value = serde_json::from_str(text).unwrap(); + json["progress_percent"].as_u64().unwrap() + } + + #[tokio::test] + async fn view_progress_is_zero_for_empty_plan() { + assert_eq!(progress_percent(vec![]).await, 0); + } + + #[tokio::test] + async fn view_progress_reports_completed_share() { + let steps = vec![ + step(1, StepStatus::Completed), + step(2, StepStatus::Pending), + step(3, StepStatus::Pending), + ]; + // 1 of 3 completed, integer division + assert_eq!(progress_percent(steps).await, 33); + } +}