Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 136 additions & 0 deletions .github/workflows/testplan.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
name: Generate TestPlan

on:
pull_request:
types: [opened, synchronize, reopened]
paths:
- '**.go'
- '**.c'
- '**.h'
- 'test/distributed/**'

concurrency:
group: testplan-${{ github.event.pull_request.number }}
cancel-in-progress: true

jobs:
generate-testplan:
name: Generate TestPlan
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod

- name: Build mo-testplan
run: go build -o mo-testplan ./cmd/mo-testplan

- name: Generate diff
run: |
git diff origin/${{ github.base_ref }}...${{ github.event.pull_request.head.sha }} > /tmp/pr.diff

- name: Generate TestPlan (JSON)
run: |
./mo-testplan \
--pr ${{ github.event.pull_request.number }} \
--base ${{ github.base_ref }} \
--head ${{ github.head_ref }} \
--diff /tmp/pr.diff \
--format json > /tmp/testplan.json

- name: Generate TestPlan (Summary)
id: summary
run: |
SUMMARY=$(./mo-testplan \
--pr ${{ github.event.pull_request.number }} \
--base ${{ github.base_ref }} \
--head ${{ github.head_ref }} \
--diff /tmp/pr.diff \
--format summary)
echo "testplan<<EOF" >> $GITHUB_OUTPUT
echo "$SUMMARY" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT

- name: Upload TestPlan artifact
uses: actions/upload-artifact@v4
with:
name: testplan-pr${{ github.event.pull_request.number }}
path: /tmp/testplan.json

- name: Comment on PR
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const testplan = JSON.parse(fs.readFileSync('/tmp/testplan.json', 'utf8'));
const taskCount = testplan.tasks.length;
const utTasks = testplan.tasks.filter(t => t.type === 'unit_test').length;
const bvtTasks = testplan.tasks.filter(t => t.type === 'bvt').length;
const scaTasks = testplan.tasks.filter(t => t.type === 'sca').length;

const body = `## 🤖 AI TestPlan Generated

**PR #${testplan.pr_number}** (${testplan.head_branch} → ${testplan.base_branch})

| Metric | Count |
|--------|-------|
| Files Changed | ${testplan.diff_summary.files.length} |
| Lines Added | ${testplan.diff_summary.total_added} |
| Lines Deleted | ${testplan.diff_summary.total_deleted} |
| **Total Tasks** | **${taskCount}** |
| Unit Tests | ${utTasks} |
| BVT Tests | ${bvtTasks} |
| Static Analysis | ${scaTasks} |

<details>
<summary>📋 Task Details</summary>

\`\`\`
${process.env.TESTPLAN_SUMMARY || 'See artifact for details'}
\`\`\`

</details>

<details>
<summary>📦 Affected Packages</summary>

${[...new Set(testplan.diff_summary.files.filter(f => f.package).map(f => f.package))].map(p => '- `' + p + '`').join('\n')}

</details>

> 💡 Full TestPlan JSON available as workflow artifact.
`;

// Find existing comment
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const botComment = comments.find(c => c.body.includes('🤖 AI TestPlan Generated'));

if (botComment) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body: body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: body,
});
}
env:
TESTPLAN_SUMMARY: ${{ steps.summary.outputs.testplan }}
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,6 @@ etc/docker-multi-cn-local-disk/*.toml
# python
**/__pycache__/
*.pyc

# built CLI tools
mo-testplan
107 changes: 107 additions & 0 deletions cmd/mo-testplan/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// Copyright 2024 Matrix Origin
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

// mo-testplan is a CLI tool that generates a structured TestPlan from a
// git diff. It is designed to be used in CI pipelines (e.g., GitHub Actions)
// to automatically determine which tests to run for a given PR.
//
// Usage:
//
// # From git diff on stdin:
// git diff origin/main...HEAD | mo-testplan --pr 12345 --base main --head feature/x
//
// # From a diff file:
// mo-testplan --pr 12345 --base main --head feature/x --diff changes.patch
//
// # Output format:
// mo-testplan --pr 12345 --base main --head feature/x --format json
// mo-testplan --pr 12345 --base main --head feature/x --format summary
package main

import (
"flag"
"fmt"
"io"
"os"

"github.com/matrixorigin/matrixone/pkg/testinfra/planner"
)

func main() {
var (
prNumber int
baseBranch string
headBranch string
diffFile string
format string
)

flag.IntVar(&prNumber, "pr", 0, "PR number")
flag.StringVar(&baseBranch, "base", "main", "base branch name")
flag.StringVar(&headBranch, "head", "", "head branch name")
flag.StringVar(&diffFile, "diff", "", "path to diff file (reads stdin if empty)")
flag.StringVar(&format, "format", "json", "output format: json or summary")
flag.Parse()

// Read diff
var diffBytes []byte
var err error
if diffFile != "" {
diffBytes, err = os.ReadFile(diffFile)
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading diff file: %v\n", err)
os.Exit(1)
}
} else {
diffBytes, err = io.ReadAll(os.Stdin)
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading stdin: %v\n", err)
os.Exit(1)
}
}

if len(diffBytes) == 0 {
fmt.Fprintf(os.Stderr, "No diff provided. Pipe a git diff or use --diff flag.\n")
os.Exit(1)
}

// Generate plan
p := planner.NewPlanner()
plan := p.GeneratePlanFromDiff(string(diffBytes), prNumber, baseBranch, headBranch)

// Output
switch format {
case "json":
data, err := plan.ToJSON()
if err != nil {
fmt.Fprintf(os.Stderr, "Error serializing plan: %v\n", err)
os.Exit(1)
}
fmt.Println(string(data))
case "summary":
fmt.Println(plan.Summary)
fmt.Printf("\nTasks (%d):\n", len(plan.Tasks))
for _, task := range plan.Tasks {
target := string(task.Package)
if target == "" {
target = string(task.Category)
}
fmt.Printf(" [%s] %s %-8s %s (%s)\n",
task.Priority.String(), task.ID, task.Type, target, task.Reason)
}
default:
fmt.Fprintf(os.Stderr, "Unknown format: %s (use json or summary)\n", format)
os.Exit(1)
}
}
Loading
Loading