From 3b5684def946b63223bf2e5835756d29046800a2 Mon Sep 17 00:00:00 2001 From: yuluo-yx Date: Sat, 6 Sep 2025 16:54:34 +0800 Subject: [PATCH 1/8] feat: optimize classifier and add ttft unit test Signed-off-by: yuluo-yx --- .../pkg/utils/classification/classifier.go | 91 +++++++------------ .../pkg/utils/ttft/calculator_test.go | 56 ++++++++++++ 2 files changed, 89 insertions(+), 58 deletions(-) create mode 100644 src/semantic-router/pkg/utils/ttft/calculator_test.go diff --git a/src/semantic-router/pkg/utils/classification/classifier.go b/src/semantic-router/pkg/utils/classification/classifier.go index 97ed00e0..5846fc8e 100644 --- a/src/semantic-router/pkg/utils/classification/classifier.go +++ b/src/semantic-router/pkg/utils/classification/classifier.go @@ -3,6 +3,7 @@ package classification import ( "fmt" "log" + "slices" "strings" "sync" "time" @@ -466,35 +467,24 @@ func (c *Classifier) SelectBestModelForCategory(categoryName string) string { bestQuality := 0.0 if c.Config.Classifier.LoadAware { - // Load-aware: combine accuracy and TTFT - for _, modelScore := range cat.ModelScores { + c.forEachModelScore(cat, func(modelScore config.ModelScore) { quality := modelScore.Score model := modelScore.Model - baseTTFT := c.ModelTTFT[model] load := c.ModelLoad[model] estTTFT := baseTTFT * (1 + float64(load)) if estTTFT == 0 { - estTTFT = 1 // avoid div by zero + estTTFT = 1 } score := quality / estTTFT - if score > bestScore { - bestScore = score - bestModel = model - bestQuality = quality - } - } + c.updateBestModel(score, quality, model, &bestScore, &bestQuality, &bestModel) + }) } else { - // Not load-aware: pick the model with the highest accuracy only - for _, modelScore := range cat.ModelScores { + c.forEachModelScore(cat, func(modelScore config.ModelScore) { quality := modelScore.Score model := modelScore.Model - if quality > bestScore { - bestScore = quality - bestModel = model - bestQuality = quality - } - } + c.updateBestModel(quality, quality, model, &bestScore, &bestQuality, &bestModel) + }) } if bestModel == "" { @@ -507,6 +497,13 @@ func (c *Classifier) SelectBestModelForCategory(categoryName string) string { return bestModel } +// forEachModelScore 遍历 category 的 ModelScores 并对每个元素执行回调 +func (c *Classifier) forEachModelScore(cat *config.Category, fn func(modelScore config.ModelScore)) { + for _, modelScore := range cat.ModelScores { + fn(modelScore) + } +} + // SelectBestModelFromList selects the best model from a list of candidate models for a given category func (c *Classifier) SelectBestModelFromList(candidateModels []string, categoryName string) string { if len(candidateModels) == 0 { @@ -534,17 +531,13 @@ func (c *Classifier) SelectBestModelFromList(candidateModels []string, categoryN bestScore := -1.0 bestQuality := 0.0 - if c.Config.Classifier.LoadAware { - // Load-aware: combine accuracy and TTFT - for _, modelScore := range cat.ModelScores { - model := modelScore.Model - - // Check if this model is in the candidate list - if !c.contains(candidateModels, model) { - continue - } - - quality := modelScore.Score + filteredFn := func(modelScore config.ModelScore) { + model := modelScore.Model + if !slices.Contains(candidateModels, model) { + return + } + quality := modelScore.Score + if c.Config.Classifier.LoadAware { baseTTFT := c.ModelTTFT[model] load := c.ModelLoad[model] estTTFT := baseTTFT * (1 + float64(load)) @@ -552,31 +545,14 @@ func (c *Classifier) SelectBestModelFromList(candidateModels []string, categoryN estTTFT = 1 // avoid div by zero } score := quality / estTTFT - if score > bestScore { - bestScore = score - bestModel = model - bestQuality = quality - } - } - } else { - // Not load-aware: pick the model with the highest accuracy only - for _, modelScore := range cat.ModelScores { - model := modelScore.Model - - // Check if this model is in the candidate list - if !c.contains(candidateModels, model) { - continue - } - - quality := modelScore.Score - if quality > bestScore { - bestScore = quality - bestModel = model - bestQuality = quality - } + c.updateBestModel(score, quality, model, &bestScore, &bestQuality, &bestModel) + } else { + c.updateBestModel(quality, quality, model, &bestScore, &bestQuality, &bestModel) } } + c.forEachModelScore(cat, filteredFn) + if bestModel == "" { log.Printf("No suitable model found from candidates for category %s, using first candidate", categoryName) return candidateModels[0] @@ -619,12 +595,11 @@ func (c *Classifier) DecrementModelLoad(model string) { } } -// contains checks if a slice contains a string -func (c *Classifier) contains(slice []string, item string) bool { - for _, s := range slice { - if s == item { - return true - } +// updateBestModel updates the best model, score, and quality if the new score is better. +func (c *Classifier) updateBestModel(score, quality float64, model string, bestScore *float64, bestQuality *float64, bestModel *string) { + if score > *bestScore { + *bestScore = score + *bestModel = model + *bestQuality = quality } - return false } diff --git a/src/semantic-router/pkg/utils/ttft/calculator_test.go b/src/semantic-router/pkg/utils/ttft/calculator_test.go new file mode 100644 index 00000000..bf4b3fa1 --- /dev/null +++ b/src/semantic-router/pkg/utils/ttft/calculator_test.go @@ -0,0 +1,56 @@ +package ttft + +import ( + "testing" + + "github.com/vllm-project/semantic-router/semantic-router/pkg/config" +) + +func TestComputeBaseTTFT(t *testing.T) { + + gpuConfig := config.GPUConfig{ + FLOPS: 1e12, // 1 TFLOP + HBM: 1e11, // 100 GB/s + } + calculator := NewCalculator(gpuConfig) + + routerCfg := &config.RouterConfig{} + // Mock config methods if needed, or set up fields so that + // GetModelParamCount, GetModelBatchSize, GetModelContextSize return defaults + + ttft := calculator.ComputeBaseTTFT("test-model", routerCfg) + if ttft <= 0 { + t.Errorf("Expected TTFT > 0, got %f", ttft) + } +} + +func TestInitializeModelTTFT(t *testing.T) { + gpuConfig := config.GPUConfig{ + FLOPS: 1e12, + HBM: 1e11, + } + calculator := NewCalculator(gpuConfig) + + // Minimal mock config with two categories and models + routerCfg := &config.RouterConfig{ + Categories: []config.Category{ + { + ModelScores: []config.ModelScore{ + {Model: "model-a", Score: 0.9}, + {Model: "model-b", Score: 0.8}, + }, + }, + }, + DefaultModel: "model-default", + } + + modelTTFT := calculator.InitializeModelTTFT(routerCfg) + if len(modelTTFT) != 3 { + t.Errorf("Expected 3 models in TTFT map, got %d", len(modelTTFT)) + } + for model, ttft := range modelTTFT { + if ttft <= 0 { + t.Errorf("Model %s has non-positive TTFT: %f", model, ttft) + } + } +} From 1120cd296106fa6070f4fc1159299e954f190249 Mon Sep 17 00:00:00 2001 From: bitliu Date: Wed, 3 Sep 2025 11:30:17 +0800 Subject: [PATCH 2/8] project: add v0.1 roadmap Signed-off-by: bitliu Signed-off-by: yuluo-yx --- website/docusaurus.config.js | 11 + website/src/pages/roadmap/roadmap.module.css | 431 +++++++++++++++++++ website/src/pages/roadmap/v0.1.js | 254 +++++++++++ 3 files changed, 696 insertions(+) create mode 100644 website/src/pages/roadmap/roadmap.module.css create mode 100644 website/src/pages/roadmap/v0.1.js diff --git a/website/docusaurus.config.js b/website/docusaurus.config.js index 82006acf..b5358fb2 100644 --- a/website/docusaurus.config.js +++ b/website/docusaurus.config.js @@ -112,6 +112,17 @@ const config = { }, ], }, + { + type: 'dropdown', + label: 'Roadmap', + position: 'left', + items: [ + { + label: 'v0.1', + to: '/roadmap/v0.1', + }, + ], + }, { href: 'https://github.com/vllm-project/semantic-router', label: 'GitHub', diff --git a/website/src/pages/roadmap/roadmap.module.css b/website/src/pages/roadmap/roadmap.module.css new file mode 100644 index 00000000..1d05c91d --- /dev/null +++ b/website/src/pages/roadmap/roadmap.module.css @@ -0,0 +1,431 @@ +/* Tech-inspired background with animated grid */ +@keyframes gridMove { + 0% { transform: translate(0, 0); } + 100% { transform: translate(20px, 20px); } +} + +@keyframes glow { + 0%, 100% { box-shadow: 0 0 5px rgba(0, 123, 255, 0.3); } + 50% { box-shadow: 0 0 20px rgba(0, 123, 255, 0.6), 0 0 30px rgba(0, 123, 255, 0.4); } +} + +@keyframes pulse { + 0%, 100% { opacity: 0.8; } + 50% { opacity: 1; } +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.subtitle { + font-size: 1.3rem; + background: linear-gradient(135deg, #007bff, #00d4ff); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + margin-bottom: 2rem; + font-weight: 600; + text-align: center; + animation: slideIn 0.8s ease-out; +} + +.goalSection { + background: linear-gradient(135deg, rgba(0, 123, 255, 0.08), rgba(0, 212, 255, 0.08)); + border: 1px solid rgba(0, 123, 255, 0.25); + padding: 2rem; + border-radius: 12px; + margin-bottom: 2rem; + position: relative; + overflow: hidden; + animation: slideIn 0.8s ease-out 0.2s both; + color: var(--ifm-color-emphasis-800); +} + +.goalSection::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-image: + linear-gradient(rgba(0, 123, 255, 0.06) 1px, transparent 1px), + linear-gradient(90deg, rgba(0, 123, 255, 0.06) 1px, transparent 1px); + background-size: 20px 20px; + animation: gridMove 20s linear infinite; + pointer-events: none; +} + +.goalSection h2, +.goalSection h3 { + color: var(--ifm-color-emphasis-900); +} + +.keyDeliverables { + margin-top: 1.5rem; + padding-top: 1.5rem; + border-top: 2px solid transparent; + background: linear-gradient(90deg, rgba(0, 123, 255, 0.4), rgba(0, 212, 255, 0.4)) top/100% 2px no-repeat; +} + +.priorityLegend { + background: linear-gradient(135deg, rgba(0, 123, 255, 0.08), rgba(0, 212, 255, 0.08)); + border: 1px solid rgba(0, 123, 255, 0.3); + border-radius: 12px; + padding: 1.5rem; + margin-bottom: 2rem; + backdrop-filter: blur(10px); + position: relative; + animation: slideIn 0.8s ease-out 0.4s both; + color: var(--ifm-color-emphasis-800); +} + +.priorityLegend::before { + content: ''; + position: absolute; + top: -1px; + left: -1px; + right: -1px; + bottom: -1px; + background: linear-gradient(45deg, #007bff, #00d4ff, #007bff); + border-radius: 12px; + z-index: -1; + animation: glow 3s ease-in-out infinite; +} + +.priorityLegend h3 { + color: var(--ifm-color-emphasis-900); + margin-bottom: 1rem; +} + +.priorityItems { + display: flex; + flex-direction: column; + gap: 1rem; + margin-top: 1rem; +} + +.priorityItem { + display: flex; + align-items: flex-start; + gap: 1rem; + padding: 0.75rem; + border-radius: 8px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(0, 123, 255, 0.15); + transition: all 0.3s ease; +} + +.priorityItem:hover { + background: rgba(255, 255, 255, 0.08); + border-color: rgba(0, 123, 255, 0.3); + transform: translateX(5px); +} + +.priorityItem strong { + color: var(--ifm-color-emphasis-900); +} + +.priorityItem p { + margin: 0.25rem 0 0 0; + color: var(--ifm-color-emphasis-700); + font-size: 0.9rem; +} + +.priorityBadge { + display: inline-block; + padding: 0.4rem 0.8rem; + border-radius: 20px; + color: white; + font-weight: bold; + font-size: 0.75rem; + text-align: center; + min-width: 2.5rem; + flex-shrink: 0; + position: relative; + overflow: hidden; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); + animation: pulse 2s ease-in-out infinite; +} + +.priorityBadge::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent); + transition: left 0.5s; +} + +.priorityBadge:hover::before { + left: 100%; +} + +.areaSection { + margin-bottom: 3rem; + border: 1px solid rgba(0, 123, 255, 0.25); + border-radius: 12px; + overflow: hidden; + background: linear-gradient(135deg, rgba(255, 255, 255, 0.03), rgba(0, 123, 255, 0.03)); + backdrop-filter: blur(5px); + position: relative; + animation: slideIn 0.8s ease-out var(--delay, 0.6s) both; +} + +.areaSection::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: linear-gradient(90deg, #007bff, #00d4ff, #007bff); + animation: glow 3s ease-in-out infinite; +} + +.areaTitle { + background: linear-gradient(135deg, #007bff, #0056b3); + color: white; + margin: 0; + padding: 1.2rem 1.5rem; + font-size: 1.3rem; + font-weight: 600; + position: relative; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); +} + +.areaTitle::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 2px; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent); +} + +.areaContent { + padding: 2rem 1.5rem; + background: rgba(255, 255, 255, 0.02); + color: var(--ifm-color-emphasis-800); +} + +.subsection { + margin-bottom: 2.5rem; + position: relative; +} + +.subsection:last-child { + margin-bottom: 0; +} + +.subsection h4 { + color: #007bff; + border-bottom: none; + padding: 0.75rem 1rem; + margin-bottom: 1.5rem; + background: linear-gradient(135deg, rgba(0, 123, 255, 0.1), rgba(0, 212, 255, 0.1)); + border-left: 4px solid #007bff; + border-radius: 0 8px 8px 0; + font-weight: 600; + position: relative; + overflow: hidden; +} + +.subsection h4::before { + content: ''; + position: absolute; + top: 0; + left: 0; + bottom: 0; + width: 4px; + background: linear-gradient(180deg, #007bff, #00d4ff); + animation: pulse 2s ease-in-out infinite; +} + +.roadmapItem { + background: linear-gradient(135deg, rgba(255, 255, 255, 0.08), rgba(0, 123, 255, 0.04)); + border: 1px solid rgba(0, 123, 255, 0.2); + border-radius: 10px; + padding: 1.25rem; + margin-bottom: 1.25rem; + position: relative; + transition: all 0.3s ease; + backdrop-filter: blur(5px); + color: var(--ifm-color-emphasis-800); +} + +.roadmapItem::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + border-radius: 10px; + background: linear-gradient(135deg, rgba(0, 123, 255, 0.08), rgba(0, 212, 255, 0.08)); + opacity: 0; + transition: opacity 0.3s ease; + pointer-events: none; +} + +.roadmapItem:hover { + transform: translateY(-2px); + border-color: rgba(0, 123, 255, 0.4); + box-shadow: + 0 8px 25px rgba(0, 123, 255, 0.15), + 0 0 0 1px rgba(0, 123, 255, 0.1); +} + +.roadmapItem:hover::before { + opacity: 1; +} + +.itemHeader { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 0.75rem; + gap: 1rem; +} + +.itemTitle { + margin: 0; + font-size: 1.1rem; + font-weight: 600; + color: var(--ifm-color-emphasis-900); + flex: 1; + position: relative; +} + +.itemTitle::after { + content: ''; + position: absolute; + bottom: -2px; + left: 0; + width: 0; + height: 2px; + background: linear-gradient(90deg, #007bff, #00d4ff); + transition: width 0.3s ease; +} + +.roadmapItem:hover .itemTitle::after { + width: 100%; +} + +.itemDescription { + margin-bottom: 1.25rem; + color: var(--ifm-color-emphasis-700); + line-height: 1.6; + font-size: 0.95rem; +} + +.acceptance { + background: linear-gradient(135deg, rgba(40, 167, 69, 0.08), rgba(25, 135, 84, 0.08)); + border: 1px solid rgba(40, 167, 69, 0.25); + border-left: 4px solid #28a745; + padding: 1rem; + border-radius: 8px; + font-size: 0.9rem; + line-height: 1.5; + position: relative; + overflow: hidden; + color: var(--ifm-color-emphasis-800); +} + +.acceptance::before { + content: ''; + position: absolute; + top: 0; + left: 0; + bottom: 0; + width: 4px; + background: linear-gradient(180deg, #28a745, #20c997); + animation: pulse 3s ease-in-out infinite; +} + +.acceptance strong { + color: #1e7e34; + font-weight: 700; +} + +/* Tech-inspired scrollbar */ +::-webkit-scrollbar { + width: 8px; +} + +::-webkit-scrollbar-track { + background: rgba(0, 123, 255, 0.1); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb { + background: linear-gradient(180deg, #007bff, #00d4ff); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: linear-gradient(180deg, #0056b3, #007bff); +} + +/* Staggered animation delays for area sections */ +.areaSection:nth-child(1) { --delay: 0.6s; } +.areaSection:nth-child(2) { --delay: 0.8s; } +.areaSection:nth-child(3) { --delay: 1.0s; } +.areaSection:nth-child(4) { --delay: 1.2s; } +.areaSection:nth-child(5) { --delay: 1.4s; } +.areaSection:nth-child(6) { --delay: 1.6s; } +.areaSection:nth-child(7) { --delay: 1.8s; } +.areaSection:nth-child(8) { --delay: 2.0s; } + +/* Responsive design */ +@media (max-width: 768px) { + .priorityItems { + gap: 1.5rem; + } + + .priorityItem { + flex-direction: column; + gap: 0.5rem; + } + + .itemHeader { + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + } + + .goalSection, + .priorityLegend, + .areaContent { + padding: 1rem; + } + + .subtitle { + font-size: 1.1rem; + } + + .areaTitle { + font-size: 1.1rem; + padding: 1rem; + } + + .roadmapItem { + padding: 1rem; + } + + /* Reduce animation delays on mobile */ + .areaSection { + --delay: 0.2s; + } +} diff --git a/website/src/pages/roadmap/v0.1.js b/website/src/pages/roadmap/v0.1.js new file mode 100644 index 00000000..dd8dea3d --- /dev/null +++ b/website/src/pages/roadmap/v0.1.js @@ -0,0 +1,254 @@ +import React from 'react'; +import Layout from '@theme/Layout'; +import styles from './roadmap.module.css'; + +const priorityColors = { + 'P0': '#dc3545', // Red for critical + 'P1': '#fd7e14', // Orange for important + 'P2': '#6c757d', // Gray for nice-to-have +}; + +const PriorityBadge = ({ priority }) => ( + + {priority} + +); + +const RoadmapItem = ({ title, priority, acceptance, children }) => ( +
+
+

{title}

+ +
+ {children &&
{children}
} + {acceptance && ( +
+ Acceptance: {acceptance} +
+ )} +
+); + +const AreaSection = ({ title, children }) => ( +
+

{title}

+
+ {children} +
+
+); + +export default function RoadmapV01() { + return ( + +
+
+
+

Roadmap v0.1

+

+ Productizing Intelligent Routing with Comprehensive Evaluation +

+ +
+

Release Goal

+

+ This release focuses on productizing the semantic router with: +

+
    +
  1. Intelligent routing with configurable reasoning modes and model-family-aware templating
  2. +
  3. Kubernetes-native deployment with auto-configuration from model evaluation
  4. +
  5. Comprehensive benchmarking and monitoring beyond MMLU-Pro
  6. +
  7. Production-ready caching and observability
  8. +
+ +
+

Key P0 Deliverables

+
    +
  • Router intelligence: Reasoning controller, ExtProc plugins, semantic caching
  • +
  • Operations: K8s operator, benchmarks, monitoring
  • +
  • Quality: Test coverage, integration tests, structured logging
  • +
+
+
+ +
+

Priority Criteria

+
+
+ +
+ Critical / Must-Have +

Directly impacts core functionality or correctness. Without this, the system cannot be reliably used in production.

+
+
+
+ +
+ Important / Should-Have +

Improves system quality, efficiency, or usability but is not blocking the basic workflow.

+
+
+
+ +
+ Nice-to-Have / Exploratory +

Experimental or advanced features that extend system capability.

+
+
+
+
+ + +
+

Model Selection and Configuration

+ +
+ +
+

Routing Logic

+ + +
+ +
+

Semantic Cache

+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ ); +} From f4952a4fe378cdfdb052e5dd77809aefcedf3f08 Mon Sep 17 00:00:00 2001 From: bitliu Date: Sat, 6 Sep 2025 10:11:06 +0800 Subject: [PATCH 3/8] update Signed-off-by: bitliu Signed-off-by: yuluo-yx --- website/src/pages/roadmap/v0.1.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/pages/roadmap/v0.1.js b/website/src/pages/roadmap/v0.1.js index dd8dea3d..93cfd7e1 100644 --- a/website/src/pages/roadmap/v0.1.js +++ b/website/src/pages/roadmap/v0.1.js @@ -233,7 +233,7 @@ export default function RoadmapV01() { Date: Sat, 6 Sep 2025 10:25:26 +0800 Subject: [PATCH 4/8] add task index Signed-off-by: bitliu Signed-off-by: yuluo-yx --- website/src/pages/roadmap/roadmap.module.css | 39 +++++++++++++++++ website/src/pages/roadmap/v0.1.js | 46 ++++++++++++++------ 2 files changed, 71 insertions(+), 14 deletions(-) diff --git a/website/src/pages/roadmap/roadmap.module.css b/website/src/pages/roadmap/roadmap.module.css index 1d05c91d..3f59c0cd 100644 --- a/website/src/pages/roadmap/roadmap.module.css +++ b/website/src/pages/roadmap/roadmap.module.css @@ -323,6 +323,45 @@ width: 100%; } +.taskLink { + color: #007bff; + text-decoration: none; + font-weight: 700; + font-size: 0.9rem; + padding: 0.2rem 0.5rem; + border-radius: 4px; + background: rgba(0, 123, 255, 0.1); + border: 1px solid rgba(0, 123, 255, 0.2); + transition: all 0.3s ease; + display: inline-block; + margin-right: 0.5rem; + position: relative; + overflow: hidden; +} + +.taskLink::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent); + transition: left 0.3s ease; +} + +.taskLink:hover { + color: #0056b3; + background: rgba(0, 123, 255, 0.15); + border-color: rgba(0, 123, 255, 0.4); + text-decoration: none; + transform: scale(1.05); +} + +.taskLink:hover::before { + left: 100%; +} + .itemDescription { margin-bottom: 1.25rem; color: var(--ifm-color-emphasis-700); diff --git a/website/src/pages/roadmap/v0.1.js b/website/src/pages/roadmap/v0.1.js index 93cfd7e1..626513f9 100644 --- a/website/src/pages/roadmap/v0.1.js +++ b/website/src/pages/roadmap/v0.1.js @@ -9,7 +9,7 @@ const priorityColors = { }; const PriorityBadge = ({ priority }) => ( - @@ -17,20 +17,35 @@ const PriorityBadge = ({ priority }) => ( ); -const RoadmapItem = ({ title, priority, acceptance, children }) => ( -
-
-

{title}

- -
- {children &&
{children}
} - {acceptance && ( -
- Acceptance: {acceptance} +// Counter for generating unique task numbers +let taskCounter = 0; + +const RoadmapItem = ({ title, priority, acceptance, children, id }) => { + taskCounter++; + const taskId = id || `task-${taskCounter}`; + const taskNumber = taskCounter; + + return ( +
+
+

+ + #{taskNumber} + + {' '} + {title} +

+
- )} -
-); + {children &&
{children}
} + {acceptance && ( +
+ Acceptance: {acceptance} +
+ )} +
+ ); +}; const AreaSection = ({ title, children }) => (
@@ -42,6 +57,9 @@ const AreaSection = ({ title, children }) => ( ); export default function RoadmapV01() { + // Reset task counter for consistent numbering on re-renders + taskCounter = 0; + return ( Date: Sat, 6 Sep 2025 10:28:30 +0800 Subject: [PATCH 5/8] update Signed-off-by: bitliu Signed-off-by: yuluo-yx --- website/src/pages/roadmap/v0.1.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/website/src/pages/roadmap/v0.1.js b/website/src/pages/roadmap/v0.1.js index 626513f9..c9b05d26 100644 --- a/website/src/pages/roadmap/v0.1.js +++ b/website/src/pages/roadmap/v0.1.js @@ -167,6 +167,11 @@ export default function RoadmapV01() { priority="P2" acceptance="Online model score updates based on model accuracy, latency, and cost metrics; auto-updates model_scores in config; replaces static scoring in A/B test or through RL." /> + From 2d7fb6a6e6216d0f884a30102e3d7216d1555b3e Mon Sep 17 00:00:00 2001 From: shown Date: Sat, 6 Sep 2025 17:53:56 +0800 Subject: [PATCH 6/8] update comment in forEachModelScore method Update comment to improve clarity and correctness. Signed-off-by: yuluo-yx --- src/semantic-router/pkg/utils/classification/classifier.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/semantic-router/pkg/utils/classification/classifier.go b/src/semantic-router/pkg/utils/classification/classifier.go index 5846fc8e..e288cac2 100644 --- a/src/semantic-router/pkg/utils/classification/classifier.go +++ b/src/semantic-router/pkg/utils/classification/classifier.go @@ -497,7 +497,7 @@ func (c *Classifier) SelectBestModelForCategory(categoryName string) string { return bestModel } -// forEachModelScore 遍历 category 的 ModelScores 并对每个元素执行回调 +// forEachModelScore traverses the ModelScores document of the category and executes the callback for each element。 func (c *Classifier) forEachModelScore(cat *config.Category, fn func(modelScore config.ModelScore)) { for _, modelScore := range cat.ModelScores { fn(modelScore) From d612c34ae5080a4607970ebd1de99338553a3391 Mon Sep 17 00:00:00 2001 From: yuluo-yx Date: Sat, 6 Sep 2025 18:20:41 +0800 Subject: [PATCH 7/8] feat: add ut and split ttft ut Signed-off-by: yuluo-yx --- .../utils/classification/classifier_test.go | 46 +++++++++++++++ .../pkg/utils/ttft/calculator_test.go | 56 ------------------- 2 files changed, 46 insertions(+), 56 deletions(-) delete mode 100644 src/semantic-router/pkg/utils/ttft/calculator_test.go diff --git a/src/semantic-router/pkg/utils/classification/classifier_test.go b/src/semantic-router/pkg/utils/classification/classifier_test.go index afa60092..32682d28 100644 --- a/src/semantic-router/pkg/utils/classification/classifier_test.go +++ b/src/semantic-router/pkg/utils/classification/classifier_test.go @@ -406,3 +406,49 @@ var _ = Describe("PIIClassification", func() { }) }) }) + +func TestUpdateBestModel(t *testing.T) { + + classifier := &Classifier{} + + bestScore := 0.5 + bestQuality := 0.5 + bestModel := "old-model" + + classifier.updateBestModel(0.8, 0.9, "new-model", &bestScore, &bestQuality, &bestModel) + if bestScore != 0.8 || bestQuality != 0.9 || bestModel != "new-model" { + t.Errorf("update: got bestScore=%v, bestQuality=%v, bestModel=%v", bestScore, bestQuality, bestModel) + } + + classifier.updateBestModel(0.7, 0.7, "another-model", &bestScore, &bestQuality, &bestModel) + if bestScore != 0.8 || bestQuality != 0.9 || bestModel != "new-model" { + t.Errorf("not update: got bestScore=%v, bestQuality=%v, bestModel=%v", bestScore, bestQuality, bestModel) + } +} + +func TestForEachModelScore(t *testing.T) { + + c := &Classifier{} + cat := &config.Category{ + ModelScores: []config.ModelScore{ + {Model: "model-a", Score: 0.9}, + {Model: "model-b", Score: 0.8}, + {Model: "model-c", Score: 0.7}, + }, + } + + var models []string + c.forEachModelScore(cat, func(ms config.ModelScore) { + models = append(models, ms.Model) + }) + + expected := []string{"model-a", "model-b", "model-c"} + if len(models) != len(expected) { + t.Fatalf("expected %d models, got %d", len(expected), len(models)) + } + for i, m := range expected { + if models[i] != m { + t.Errorf("expected model %s at index %d, got %s", m, i, models[i]) + } + } +} diff --git a/src/semantic-router/pkg/utils/ttft/calculator_test.go b/src/semantic-router/pkg/utils/ttft/calculator_test.go deleted file mode 100644 index bf4b3fa1..00000000 --- a/src/semantic-router/pkg/utils/ttft/calculator_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package ttft - -import ( - "testing" - - "github.com/vllm-project/semantic-router/semantic-router/pkg/config" -) - -func TestComputeBaseTTFT(t *testing.T) { - - gpuConfig := config.GPUConfig{ - FLOPS: 1e12, // 1 TFLOP - HBM: 1e11, // 100 GB/s - } - calculator := NewCalculator(gpuConfig) - - routerCfg := &config.RouterConfig{} - // Mock config methods if needed, or set up fields so that - // GetModelParamCount, GetModelBatchSize, GetModelContextSize return defaults - - ttft := calculator.ComputeBaseTTFT("test-model", routerCfg) - if ttft <= 0 { - t.Errorf("Expected TTFT > 0, got %f", ttft) - } -} - -func TestInitializeModelTTFT(t *testing.T) { - gpuConfig := config.GPUConfig{ - FLOPS: 1e12, - HBM: 1e11, - } - calculator := NewCalculator(gpuConfig) - - // Minimal mock config with two categories and models - routerCfg := &config.RouterConfig{ - Categories: []config.Category{ - { - ModelScores: []config.ModelScore{ - {Model: "model-a", Score: 0.9}, - {Model: "model-b", Score: 0.8}, - }, - }, - }, - DefaultModel: "model-default", - } - - modelTTFT := calculator.InitializeModelTTFT(routerCfg) - if len(modelTTFT) != 3 { - t.Errorf("Expected 3 models in TTFT map, got %d", len(modelTTFT)) - } - for model, ttft := range modelTTFT { - if ttft <= 0 { - t.Errorf("Model %s has non-positive TTFT: %f", model, ttft) - } - } -} From d65b9f0a515cde1af47056426b5d8c1003e3e192 Mon Sep 17 00:00:00 2001 From: bitliu Date: Sat, 6 Sep 2025 18:23:36 +0800 Subject: [PATCH 8/8] project: add auto merge action Signed-off-by: bitliu --- .github/workflows/auto-merge.yml | 726 +++++++++++++++++++++++++++++++ 1 file changed, 726 insertions(+) create mode 100644 .github/workflows/auto-merge.yml diff --git a/.github/workflows/auto-merge.yml b/.github/workflows/auto-merge.yml new file mode 100644 index 00000000..443f01cb --- /dev/null +++ b/.github/workflows/auto-merge.yml @@ -0,0 +1,726 @@ +name: Auto Merge on Owner Approval + +on: + pull_request_review: + types: [submitted, dismissed] + check_suite: + types: [completed] + status: {} + pull_request: + types: [synchronize, labeled, unlabeled] + +jobs: + auto-merge: + runs-on: ubuntu-latest + # Only run on pull requests, not on other events + if: github.event.pull_request != null || github.event.pull_request_review != null + + permissions: + contents: write + pull-requests: write + issues: write + checks: read + statuses: read + + env: + # Configuration options + REQUIRED_CHECKS: "test-and-build" # Comma-separated list of required check names + MERGE_METHOD: "squash" # squash, merge, or rebase + SENSITIVE_PATHS: ".github/workflows/*,OWNER*" # Comma-separated patterns for sensitive files + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + # For pull_request_review events, we need to get the PR info differently + repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }} + ref: ${{ github.event.pull_request.head.sha || github.sha }} + fetch-depth: 0 + + - name: Get PR information + id: pr-info + uses: actions/github-script@v7 + with: + script: | + let prNumber; + let prData; + + // Determine PR number based on event type + if (context.eventName === 'pull_request_review') { + prNumber = context.payload.pull_request.number; + prData = context.payload.pull_request; + } else if (context.eventName === 'pull_request') { + prNumber = context.payload.pull_request.number; + prData = context.payload.pull_request; + } else if (context.eventName === 'check_suite' || context.eventName === 'status') { + // For check_suite and status events, we need to find the PR + const { data: prs } = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + head: `${context.repo.owner}:${context.payload.check_suite?.head_branch || context.payload.branches?.[0]?.name}`, + }); + + if (prs.length === 0) { + console.log('No open PR found for this commit'); + return; + } + + prNumber = prs[0].number; + prData = prs[0]; + } + + if (!prNumber) { + console.log('Could not determine PR number'); + return; + } + + // Get fresh PR data + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + }); + + console.log(`Processing PR #${prNumber}: ${pr.title}`); + console.log(`PR state: ${pr.state}, draft: ${pr.draft}, mergeable: ${pr.mergeable}`); + + // Set outputs for next steps + core.setOutput('pr_number', prNumber); + core.setOutput('pr_state', pr.state); + core.setOutput('pr_draft', pr.draft); + core.setOutput('pr_mergeable', pr.mergeable); + core.setOutput('pr_head_sha', pr.head.sha); + core.setOutput('pr_base_ref', pr.base.ref); + + return { + number: prNumber, + state: pr.state, + draft: pr.draft, + mergeable: pr.mergeable, + head_sha: pr.head.sha, + base_ref: pr.base.ref + }; + + - name: Set default outputs + id: set-defaults + uses: actions/github-script@v7 + with: + script: | + // Set default outputs to prevent JSON parsing errors + core.setOutput('required_owners', '[]'); + core.setOutput('owner_map', '{}'); + core.setOutput('all_approved', 'false'); + core.setOutput('approved_owners', '[]'); + core.setOutput('missing_approvals', '[]'); + core.setOutput('ci_passed', 'false'); + core.setOutput('failed_checks', '[]'); + core.setOutput('pending_checks', '[]'); + core.setOutput('security_passed', 'false'); + core.setOutput('security_issues', '[]'); + core.setOutput('sensitive_files', '[]'); + console.log('Default outputs set'); + + - name: Check if auto-merge should proceed + id: should-proceed + uses: actions/github-script@v7 + with: + script: | + const prNumber = ${{ steps.pr-info.outputs.pr_number }}; + const prState = '${{ steps.pr-info.outputs.pr_state }}'; + const prDraft = '${{ steps.pr-info.outputs.pr_draft }}' === 'true'; + const prMergeable = '${{ steps.pr-info.outputs.pr_mergeable }}'; + + if (!prNumber) { + console.log('No PR number available, skipping'); + return false; + } + + // Check basic conditions + if (prState !== 'open') { + console.log(`PR is not open (state: ${prState}), skipping`); + return false; + } + + if (prDraft) { + console.log('PR is in draft state, skipping auto-merge'); + return false; + } + + if (prMergeable === false) { + console.log('PR has merge conflicts, skipping auto-merge'); + return false; + } + + // Check for no-auto-merge label + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + }); + + const hasNoAutoMergeLabel = pr.labels.some(label => + label.name.toLowerCase().includes('no-auto-merge') + ); + + if (hasNoAutoMergeLabel) { + console.log('PR has no-auto-merge label, skipping'); + return false; + } + + console.log('Basic checks passed, proceeding with auto-merge evaluation'); + return true; + + - name: Get changed files + if: steps.should-proceed.outputs.result == 'true' + id: changed-files + uses: tj-actions/changed-files@v46 + with: + files: | + **/* + base_sha: ${{ github.event.pull_request.base.sha }} + sha: ${{ steps.pr-info.outputs.pr_head_sha }} + + - name: Identify owners for changed files + if: steps.should-proceed.outputs.result == 'true' + id: identify-owners + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const path = require('path'); + + // Get changed files + const changedFiles = `${{ steps.changed-files.outputs.all_changed_files }}`.split(' '); + console.log('Changed files:', changedFiles); + + // Function to find OWNER file for a given file path (reused from owner-notification.yml) + function findOwnerFile(filePath) { + const parts = filePath.split('/'); + + // Check first level directory FIRST (prioritize more specific owners) + if (parts.length > 1) { + const firstLevelDir = parts[0]; + const ownerPath = path.join(firstLevelDir, 'OWNER'); + if (fs.existsSync(ownerPath)) { + const content = fs.readFileSync(ownerPath, 'utf8'); + const owners = content.split('\n') + .filter(line => line.trim().startsWith('@')) + .map(line => line.trim()); + if (owners.length > 0) { + return { path: firstLevelDir, owners }; + } + } + } + + // Fall back to root directory + if (fs.existsSync('OWNER')) { + const content = fs.readFileSync('OWNER', 'utf8'); + const owners = content.split('\n') + .filter(line => line.trim().startsWith('@')) + .map(line => line.trim()); + if (owners.length > 0) { + return { path: '.', owners }; + } + } + + return null; + } + + // Collect all owners for changed files + const ownerMap = new Map(); + const allRequiredOwners = new Set(); + + for (const file of changedFiles) { + if (!file.trim()) continue; + + const ownerInfo = findOwnerFile(file); + if (ownerInfo) { + if (!ownerMap.has(ownerInfo.path)) { + ownerMap.set(ownerInfo.path, { + owners: ownerInfo.owners, + files: [] + }); + } + ownerMap.get(ownerInfo.path).files.push(file); + + // Add owners to the set of all required owners + for (const owner of ownerInfo.owners) { + allRequiredOwners.add(owner.replace('@', '')); + } + } + } + + if (ownerMap.size === 0) { + console.log('No owners found for changed files'); + core.setOutput('required_owners', '[]'); + core.setOutput('owner_map', '{}'); + return { required_owners: [], owner_map: {} }; + } + + const requiredOwners = Array.from(allRequiredOwners); + const ownerMapObj = Object.fromEntries(ownerMap); + + console.log('Required owners:', requiredOwners); + console.log('Owner mapping:', ownerMapObj); + + // Set outputs for next steps (override defaults) + core.setOutput('required_owners', JSON.stringify(requiredOwners)); + core.setOutput('owner_map', JSON.stringify(ownerMapObj)); + + return { + required_owners: requiredOwners, + owner_map: ownerMapObj + }; + + - name: Check approval status + if: steps.should-proceed.outputs.result == 'true' + id: check-approvals + uses: actions/github-script@v7 + with: + script: | + const prNumber = ${{ steps.pr-info.outputs.pr_number }}; + const requiredOwners = JSON.parse('${{ steps.identify-owners.outputs.required_owners }}'); + + if (requiredOwners.length === 0) { + console.log('No required owners, approval check passed'); + core.setOutput('all_approved', 'true'); + core.setOutput('approved_owners', '[]'); + core.setOutput('missing_approvals', '[]'); + return true; + } + + // Get all reviews for the PR + const { data: reviews } = await github.rest.pulls.listReviews({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + }); + + console.log(`Found ${reviews.length} reviews for PR #${prNumber}`); + + // Get the latest review from each reviewer + const latestReviews = new Map(); + for (const review of reviews) { + const reviewer = review.user.login; + if (!latestReviews.has(reviewer) || + new Date(review.submitted_at) > new Date(latestReviews.get(reviewer).submitted_at)) { + latestReviews.set(reviewer, review); + } + } + + // Check which required owners have approved + const approvedOwners = []; + const missingApprovals = []; + + for (const owner of requiredOwners) { + const latestReview = latestReviews.get(owner); + if (latestReview && latestReview.state === 'APPROVED') { + approvedOwners.push(owner); + console.log(`✓ ${owner} has approved`); + } else { + missingApprovals.push(owner); + if (latestReview) { + console.log(`✗ ${owner} has not approved (latest review: ${latestReview.state})`); + } else { + console.log(`✗ ${owner} has not reviewed`); + } + } + } + + const allApproved = missingApprovals.length === 0; + + console.log(`Approval status: ${approvedOwners.length}/${requiredOwners.length} owners approved`); + console.log(`Approved owners: ${approvedOwners.join(', ')}`); + if (missingApprovals.length > 0) { + console.log(`Missing approvals from: ${missingApprovals.join(', ')}`); + } + + // Set outputs for next steps + core.setOutput('all_approved', allApproved.toString()); + core.setOutput('approved_owners', JSON.stringify(approvedOwners)); + core.setOutput('missing_approvals', JSON.stringify(missingApprovals)); + + return allApproved; + + - name: Check CI status + if: steps.should-proceed.outputs.result == 'true' && steps.check-approvals.outputs.all_approved == 'true' + id: check-ci + uses: actions/github-script@v7 + with: + script: | + const headSha = '${{ steps.pr-info.outputs.pr_head_sha }}'; + const requiredChecks = '${{ env.REQUIRED_CHECKS }}'.split(',').map(s => s.trim()).filter(s => s); + + console.log(`Checking CI status for commit ${headSha}`); + console.log(`Required checks: ${requiredChecks.join(', ')}`); + + if (requiredChecks.length === 0) { + console.log('No required checks configured, CI check passed'); + core.setOutput('ci_passed', 'true'); + core.setOutput('failed_checks', '[]'); + return true; + } + + // Get check runs for the commit + const { data: checkRuns } = await github.rest.checks.listForRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: headSha, + }); + + // Get status checks for the commit + const { data: statusChecks } = await github.rest.repos.getCombinedStatusForRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: headSha, + }); + + console.log(`Found ${checkRuns.check_runs.length} check runs and ${statusChecks.statuses.length} status checks`); + + // Combine all checks + const allChecks = new Map(); + + // Add check runs + for (const checkRun of checkRuns.check_runs) { + allChecks.set(checkRun.name, { + name: checkRun.name, + status: checkRun.status, + conclusion: checkRun.conclusion, + type: 'check_run' + }); + } + + // Add status checks + for (const status of statusChecks.statuses) { + if (!allChecks.has(status.context)) { + allChecks.set(status.context, { + name: status.context, + status: status.state === 'pending' ? 'in_progress' : 'completed', + conclusion: status.state, + type: 'status' + }); + } + } + + // Check required checks + const failedChecks = []; + const pendingChecks = []; + + for (const requiredCheck of requiredChecks) { + const check = allChecks.get(requiredCheck); + + if (!check) { + console.log(`✗ Required check '${requiredCheck}' not found`); + failedChecks.push(requiredCheck); + continue; + } + + if (check.status !== 'completed') { + console.log(`⏳ Required check '${requiredCheck}' is still running (${check.status})`); + pendingChecks.push(requiredCheck); + continue; + } + + if (check.conclusion === 'success') { + console.log(`✓ Required check '${requiredCheck}' passed`); + } else { + console.log(`✗ Required check '${requiredCheck}' failed (${check.conclusion})`); + failedChecks.push(requiredCheck); + } + } + + const ciPassed = failedChecks.length === 0 && pendingChecks.length === 0; + + console.log(`CI status: ${ciPassed ? 'PASSED' : 'FAILED/PENDING'}`); + if (failedChecks.length > 0) { + console.log(`Failed checks: ${failedChecks.join(', ')}`); + } + if (pendingChecks.length > 0) { + console.log(`Pending checks: ${pendingChecks.join(', ')}`); + } + + // Set outputs for next steps + core.setOutput('ci_passed', ciPassed.toString()); + core.setOutput('failed_checks', JSON.stringify(failedChecks)); + core.setOutput('pending_checks', JSON.stringify(pendingChecks)); + + return ciPassed; + + - name: Check sensitive files and security + if: steps.should-proceed.outputs.result == 'true' && steps.check-approvals.outputs.all_approved == 'true' && steps.check-ci.outputs.ci_passed == 'true' + id: check-security + uses: actions/github-script@v7 + with: + script: | + const prNumber = ${{ steps.pr-info.outputs.pr_number }}; + const changedFiles = `${{ steps.changed-files.outputs.all_changed_files }}`.split(' '); + const sensitivePaths = '${{ env.SENSITIVE_PATHS }}'.split(',').map(s => s.trim()).filter(s => s); + + console.log(`Checking security for ${changedFiles.length} changed files`); + console.log(`Sensitive path patterns: ${sensitivePaths.join(', ')}`); + + // Check for sensitive file modifications + const sensitiveFiles = []; + + for (const file of changedFiles) { + if (!file.trim()) continue; + + for (const pattern of sensitivePaths) { + // Simple glob pattern matching + const regex = new RegExp(pattern.replace(/\*/g, '.*')); + if (regex.test(file)) { + sensitiveFiles.push(file); + break; + } + } + } + + // Get PR details for additional security checks + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + }); + + const securityIssues = []; + + // Check if PR modifies sensitive files + if (sensitiveFiles.length > 0) { + console.log(`⚠️ PR modifies sensitive files: ${sensitiveFiles.join(', ')}`); + securityIssues.push(`Modifies sensitive files: ${sensitiveFiles.join(', ')}`); + } + + // Check if PR is from a fork + const isFromFork = pr.head.repo.full_name !== pr.base.repo.full_name; + if (isFromFork) { + console.log('⚠️ PR is from a fork repository'); + securityIssues.push('PR is from a fork repository'); + } + + // Check if PR author is a required owner (additional security for sensitive changes) + const requiredOwners = JSON.parse('${{ steps.identify-owners.outputs.required_owners }}'); + const prAuthor = pr.user.login; + const authorIsOwner = requiredOwners.includes(prAuthor); + + if (sensitiveFiles.length > 0 && !authorIsOwner) { + console.log(`⚠️ Sensitive files modified by non-owner: ${prAuthor}`); + securityIssues.push(`Sensitive files modified by non-owner: ${prAuthor}`); + } + + // For now, we'll allow auto-merge even with security warnings, but log them + // In a production environment, you might want to block auto-merge for certain security issues + const securityPassed = true; // Could be changed to block on certain conditions + + if (securityIssues.length > 0) { + console.log(`Security warnings (${securityIssues.length}):`); + for (const issue of securityIssues) { + console.log(` - ${issue}`); + } + } else { + console.log('✅ No security issues detected'); + } + + // Set outputs for next steps + core.setOutput('security_passed', securityPassed.toString()); + core.setOutput('security_issues', JSON.stringify(securityIssues)); + core.setOutput('sensitive_files', JSON.stringify(sensitiveFiles)); + + return securityPassed; + + - name: Auto-merge evaluation + if: always() && steps.should-proceed.outputs.result == 'true' + id: auto-merge-evaluation + uses: actions/github-script@v7 + with: + script: | + const prNumber = ${{ steps.pr-info.outputs.pr_number }}; + const allApproved = '${{ steps.check-approvals.outputs.all_approved }}' === 'true'; + const ciPassed = '${{ steps.check-ci.outputs.ci_passed }}' === 'true'; + const securityPassed = '${{ steps.check-security.outputs.security_passed }}' === 'true'; + const missingApprovalsStr = '${{ steps.check-approvals.outputs.missing_approvals }}'; + const failedChecksStr = '${{ steps.check-ci.outputs.failed_checks }}'; + const pendingChecksStr = '${{ steps.check-ci.outputs.pending_checks }}'; + const securityIssuesStr = '${{ steps.check-security.outputs.security_issues }}'; + + const missingApprovals = missingApprovalsStr ? JSON.parse(missingApprovalsStr) : []; + const failedChecks = failedChecksStr ? JSON.parse(failedChecksStr) : []; + const pendingChecks = pendingChecksStr ? JSON.parse(pendingChecksStr) : []; + const securityIssues = securityIssuesStr ? JSON.parse(securityIssuesStr) : []; + + console.log(`Final auto-merge evaluation for PR #${prNumber}:`); + console.log(`- All approved: ${allApproved}`); + console.log(`- CI passed: ${ciPassed}`); + console.log(`- Security passed: ${securityPassed}`); + + if (!allApproved) { + console.log(`❌ Cannot auto-merge: missing approvals from ${missingApprovals.join(', ')}`); + return; + } + + if (!ciPassed) { + if (failedChecks.length > 0) { + console.log(`❌ Cannot auto-merge: failed CI checks: ${failedChecks.join(', ')}`); + } + if (pendingChecks.length > 0) { + console.log(`⏳ Cannot auto-merge yet: pending CI checks: ${pendingChecks.join(', ')}`); + } + return; + } + + if (!securityPassed) { + console.log(`❌ Cannot auto-merge: security issues: ${securityIssues.join(', ')}`); + return; + } + + // All checks passed - ready for merge! + console.log('🎉 All conditions met for auto-merge!'); + + // Set flag to proceed with merge + core.setOutput('ready_for_merge', 'true'); + + - name: Execute auto-merge + if: steps.auto-merge-evaluation.outputs.ready_for_merge == 'true' + id: execute-merge + uses: actions/github-script@v7 + with: + script: | + const prNumber = ${{ steps.pr-info.outputs.pr_number }}; + const mergeMethod = '${{ env.MERGE_METHOD }}'; + const requiredOwnersStr = '${{ steps.identify-owners.outputs.required_owners }}'; + const approvedOwnersStr = '${{ steps.check-approvals.outputs.approved_owners }}'; + const securityIssuesStr = '${{ steps.check-security.outputs.security_issues }}'; + + const requiredOwners = requiredOwnersStr ? JSON.parse(requiredOwnersStr) : []; + const approvedOwners = approvedOwnersStr ? JSON.parse(approvedOwnersStr) : []; + const securityIssues = securityIssuesStr ? JSON.parse(securityIssuesStr) : []; + + console.log(`Executing auto-merge for PR #${prNumber} using ${mergeMethod} method`); + + try { + // Get PR details for commit message + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + }); + + // Create commit message + let commitTitle = pr.title; + let commitMessage = ''; + + if (mergeMethod === 'squash') { + // For squash merge, include PR number and auto-merge info + commitTitle = `${pr.title} (#${prNumber})`; + commitMessage = `${pr.body || ''}\n\n`; + commitMessage += `Auto-merged by GitHub Actions after approval from: ${approvedOwners.join(', ')}\n`; + if (securityIssues.length > 0) { + commitMessage += `Security warnings: ${securityIssues.join(', ')}\n`; + } + } + + // Execute the merge + const { data: mergeResult } = await github.rest.pulls.merge({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + commit_title: commitTitle, + commit_message: commitMessage, + merge_method: mergeMethod, + }); + + console.log(`✅ Successfully merged PR #${prNumber}`); + console.log(`Merge commit SHA: ${mergeResult.sha}`); + console.log(`Merged: ${mergeResult.merged}`); + + // Set outputs for next steps + core.setOutput('merge_successful', 'true'); + core.setOutput('merge_sha', mergeResult.sha); + core.setOutput('merge_message', mergeResult.message); + + return { + success: true, + sha: mergeResult.sha, + message: mergeResult.message + }; + + } catch (error) { + console.log(`❌ Failed to merge PR #${prNumber}: ${error.message}`); + + // Set outputs for error handling + core.setOutput('merge_successful', 'false'); + core.setOutput('merge_error', error.message); + + // Re-throw to trigger failure handling + throw error; + } + + - name: Add success comment + if: steps.execute-merge.outputs.merge_successful == 'true' + uses: actions/github-script@v7 + with: + script: | + const prNumber = ${{ steps.pr-info.outputs.pr_number }}; + const mergeSha = '${{ steps.execute-merge.outputs.merge_sha }}'; + const approvedOwnersStr = '${{ steps.check-approvals.outputs.approved_owners }}'; + const ownerMapStr = '${{ steps.identify-owners.outputs.owner_map }}'; + const securityIssuesStr = '${{ steps.check-security.outputs.security_issues }}'; + const sensitiveFilesStr = '${{ steps.check-security.outputs.sensitive_files }}'; + + const approvedOwners = approvedOwnersStr ? JSON.parse(approvedOwnersStr) : []; + const ownerMap = ownerMapStr ? JSON.parse(ownerMapStr) : {}; + const securityIssues = securityIssuesStr ? JSON.parse(securityIssuesStr) : []; + const sensitiveFiles = sensitiveFilesStr ? JSON.parse(sensitiveFilesStr) : []; + + // Create success comment + let commentBody = '## 🎉 Thanks for your contributions!\n\n'; + commentBody += `This PR will be automatically merged after receiving approval from one of the required owners.\n\n`; + + // Show approval details + commentBody += '### ✅ Approvals Received\n'; + for (const owner of approvedOwners) { + commentBody += `- ${owner}\n`; + } + commentBody += '\n'; + + // Show owner mapping + if (Object.keys(ownerMap).length > 0) { + commentBody += '### 📁 Owner Mapping\n'; + for (const [dirPath, info] of Object.entries(ownerMap)) { + commentBody += `**${dirPath === '.' ? 'Root Directory' : dirPath}**: ${info.owners.join(', ')}\n`; + } + commentBody += '\n'; + } + + // Show security warnings if any + if (securityIssues.length > 0) { + commentBody += '### ⚠️ Security Warnings\n'; + for (const issue of securityIssues) { + commentBody += `- ${issue}\n`; + } + commentBody += '\n'; + } + + // Show sensitive files if any + if (sensitiveFiles.length > 0) { + commentBody += '### 🔒 Sensitive Files Modified\n'; + for (const file of sensitiveFiles) { + commentBody += `- \`${file}\`\n`; + } + commentBody += '\n'; + } + + commentBody += `**Merge commit:** ${mergeSha}\n`; + commentBody += `**Merge method:** ${{ env.MERGE_METHOD }}\n\n`; + commentBody += '---\n'; + commentBody += '*This merge was performed automatically by GitHub Actions based on owner approvals and CI status.*'; + + // Add the comment + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: commentBody + }); + + console.log(`✅ Added success comment to PR #${prNumber}`);