Skip to content
Merged
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
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ resolver = "2"
members = ["agent", "hive", "hive-hq/api", "hive-hq/types", "diff", "storage"]

[workspace.package]
version = "0.0.3"
version = "0.0.4"
edition = "2021"

[workspace.dependencies]
Expand Down
79 changes: 56 additions & 23 deletions hive-hq/api/src/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6663,7 +6663,7 @@ pub async fn post_branch(
END,
CASE
WHEN es.manifest_template_distinct_count = 1 AND es.manifest_template_single IS NOT NULL THEN es.manifest_template_single
ELSE '{cluster}/manifests/{namespace}/' || es.name || '/' || es.name || '.yaml'
ELSE '{cluster}/manifests/{namespace}/{service}/{service}.yaml'
END,
$3
FROM
Expand Down Expand Up @@ -7003,8 +7003,7 @@ pub async fn post_branch_service(
.bind(id)
.bind(&data.name)
.bind(format!(
"{{cluster}}/manifests/{{namespace}}/{}/{}.yaml",
&data.name, &data.name
"{{cluster}}/manifests/{{namespace}}/{{service}}/{{service}}.yaml"
))
.bind(tenant_id)
.execute(&mut *tx)
Expand Down Expand Up @@ -7156,12 +7155,20 @@ pub async fn post_global_repo_service(
} else {
// Use provided template or generate default
let template = data.manifest_path_template.unwrap_or_else(|| {
format!(
"{{cluster}}/manifests/{{namespace}}/{}/{}.yaml",
&data.name, &data.name
)
format!("{{cluster}}/manifests/{{namespace}}/{{service}}/{{service}}.yaml")
});

let validation = validate_path_template(&template);

if !validation.valid {
return Err((
StatusCode::BAD_REQUEST,
validation
.error
.unwrap_or_else(|| "Invalid path template".to_string()),
));
}

// TODO Instead of re-enabling a deleted service from a branch,
// ask the user if they want to re-enable the service for the deleted
// branches.
Expand Down Expand Up @@ -9131,6 +9138,14 @@ pub async fn register_repo_webhook(

set_tenant_context(&mut tx, tenant_id).await?;

let mut read_tx = state
.readonly_pool
.begin()
.await
.map_err(|e| sanitize_db_error(e, "readonly_db"))?;

set_tenant_context(&mut read_tx, tenant_id).await?;

// Get the repo info
let repo = sqlx::query_as::<_, RepoData>(
r#"
Expand All @@ -9147,7 +9162,7 @@ pub async fn register_repo_webhook(
"#,
)
.bind(repo_id)
.fetch_optional(&mut *tx)
.fetch_optional(&mut *read_tx)
.await
.map_err(|e| sanitize_db_error(e, "register_repo_webhook"))?
.ok_or((StatusCode::NOT_FOUND, "Repo not found".to_string()))?;
Expand All @@ -9165,7 +9180,7 @@ pub async fn register_repo_webhook(
r#"SELECT id, deleted_at FROM repo_webhooks WHERE repo_id = $1"#,
)
.bind(repo_id)
.fetch_optional(&mut *tx)
.fetch_optional(&mut *read_tx)
.await
.map_err(|e| sanitize_db_error(e, "register_repo_webhook"))?;

Expand All @@ -9191,7 +9206,10 @@ pub async fn register_repo_webhook(

// Make webhook routing unambiguous across hosts/org/repo by embedding repo_id.
// Query params do not affect the Axum route match.
let callback_url = format!("{}?repo_id={}", base_callback_url, repo_id);
let callback_url = format!(
"{}?repo_id={}&tenant_id={}",
base_callback_url, repo_id, tenant_id
);

// Create webhook on GitHub
let api_base_url = repo.api_base_url.trim_end_matches('/');
Expand Down Expand Up @@ -9496,6 +9514,29 @@ pub async fn receive_github_webhook(
let repo_name = &payload.repository.name;

let repo_id = params.get("repo_id").and_then(|v| Uuid::parse_str(v).ok());
let tenant_id = params
.get("tenant_id")
.and_then(|v| Uuid::parse_str(v).ok());
let tenant_id = tenant_id.ok_or((
StatusCode::BAD_REQUEST,
"Missing or invalid tenant_id query parameter".to_string(),
))?;

let mut tx = state
.pool
.begin()
.await
.map_err(|e| sanitize_db_error(e, "register_repo_webhook_begin"))?;

set_tenant_context(&mut tx, tenant_id).await?;

let mut read_tx = state
.readonly_pool
.begin()
.await
.map_err(|e| sanitize_db_error(e, "readonly_db"))?;

set_tenant_context(&mut read_tx, tenant_id).await?;

// Find the webhook and its secret.
// Prefer repo_id from callback URL query param; fall back to org/repo lookup.
Expand All @@ -9509,7 +9550,7 @@ pub async fn receive_github_webhook(
"#,
)
.bind(repo_id)
.fetch_optional(&state.readonly_pool)
.fetch_optional(&mut *read_tx)
.await
.map_err(|e| sanitize_db_error(e, "receive_github_webhook"))?
} else {
Expand All @@ -9524,7 +9565,7 @@ pub async fn receive_github_webhook(
)
.bind(org)
.bind(repo_name)
.fetch_optional(&state.readonly_pool)
.fetch_optional(&mut *read_tx)
.await
.map_err(|e| sanitize_db_error(e, "receive_github_webhook"))?
}
Expand Down Expand Up @@ -9568,14 +9609,6 @@ pub async fn receive_github_webhook(
));
}

// Start transaction and set tenant context for all subsequent queries
let mut tx = state
.pool
.begin()
.await
.map_err(|e| sanitize_db_error(e, "receive_github_webhook_begin"))?;
set_tenant_context(&mut tx, tenant_id).await?;

// Create webhook event record
let event_id = sqlx::query_scalar::<_, Uuid>(
r#"
Expand Down Expand Up @@ -9615,7 +9648,7 @@ pub async fn receive_github_webhook(
.bind(org)
.bind(repo_name)
.bind(&branch)
.fetch_all(&mut *tx)
.fetch_all(&mut *read_tx)
.await
.map_err(|e| sanitize_db_error(e, "receive_github_webhook"))?;

Expand Down Expand Up @@ -9686,7 +9719,7 @@ pub async fn receive_github_webhook(
)
.bind(namespace)
.bind(cluster)
.fetch_optional(&mut *tx)
.fetch_optional(&mut *read_tx)
.await
.map_err(|e| sanitize_db_error(e, "receive_github_webhook"))?;

Expand Down Expand Up @@ -9809,7 +9842,7 @@ pub async fn receive_github_webhook(
.bind(service_def_id)
.bind(ns_id)
.bind(&payload.after)
.fetch_optional(&mut *tx)
.fetch_optional(&mut *read_tx)
.await
.map_err(|e| sanitize_db_error(e, "receive_github_webhook"))?;

Expand Down
26 changes: 22 additions & 4 deletions hive-hq/ui/src/pages/RepoDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ import type { RepoBranch, ServiceDefinitionData, RepoWebhookEvent } from '@/type

const GITHUB_URL = 'https://github.com';

// Validate that manifest path template contains all required placeholders
function isValidManifestPathTemplate(template: string): boolean {
if (!template.trim()) return true; // Empty is valid (optional field)
return (
template.includes('{cluster}') &&
template.includes('{namespace}') &&
template.includes('{service}')
);
}

export function RepoDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
Expand Down Expand Up @@ -643,9 +653,12 @@ export function RepoDetailPage() {
id="manifestPath"
value={newServiceManifestPath}
onChange={(e) => setNewServiceManifestPath(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAddService()}
placeholder={newServiceName ? `{cluster}/manifests/{namespace}/${newServiceName}/${newServiceName}.yaml` : '{cluster}/manifests/{namespace}/{service}/{service}.yaml'}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-green-500 focus:border-green-500 dark:bg-gray-700 dark:text-white font-mono text-sm"
onKeyDown={(e) => e.key === 'Enter' && isValidManifestPathTemplate(newServiceManifestPath) && handleAddService()}
placeholder={'{cluster}/manifests/{namespace}/{service}/{service}.yaml'}
className={`w-full px-3 py-2 border rounded-md shadow-sm focus:ring-green-500 focus:border-green-500 dark:bg-gray-700 dark:text-white font-mono text-sm ${newServiceManifestPath.trim() && !isValidManifestPathTemplate(newServiceManifestPath)
? 'border-red-300 dark:border-red-600'
: 'border-gray-300 dark:border-gray-600'
}`}
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Path in git to pull manifests from. Use <code className="bg-gray-100 dark:bg-gray-600 px-1 rounded">{'{cluster}'}</code>, <code className="bg-gray-100 dark:bg-gray-600 px-1 rounded">{'{namespace}'}</code>, <code className="bg-gray-100 dark:bg-gray-600 px-1 rounded">{'{service}'}</code> as placeholders.
Expand All @@ -654,6 +667,11 @@ export function RepoDetailPage() {
• Ends with <code className="bg-gray-100 dark:bg-gray-600 px-1 rounded">.yaml</code> → watches a single file<br />
• Directory path → watches all <code className="bg-gray-100 dark:bg-gray-600 px-1 rounded">*.yaml</code> files
</p>
{newServiceManifestPath.trim() && !isValidManifestPathTemplate(newServiceManifestPath) && (
<p className="mt-2 text-xs text-red-600 dark:text-red-400">
⚠ Path must include all three placeholders: <code className="bg-red-100 dark:bg-red-900/30 px-1 rounded">{'{cluster}'}</code>, <code className="bg-red-100 dark:bg-red-900/30 px-1 rounded">{'{namespace}'}</code>, and <code className="bg-red-100 dark:bg-red-900/30 px-1 rounded">{'{service}'}</code>
</p>
)}
</div>
</div>
{addServiceMutation.isError && (
Expand All @@ -672,7 +690,7 @@ export function RepoDetailPage() {
</button>
<button
onClick={handleAddService}
disabled={!newServiceName.trim() || addServiceMutation.isPending}
disabled={!newServiceName.trim() || addServiceMutation.isPending || !isValidManifestPathTemplate(newServiceManifestPath)}
className="px-4 py-2 text-sm font-medium text-white bg-green-600 hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed rounded-md transition-colors"
>
{addServiceMutation.isPending ? 'Adding...' : 'Add Service'}
Expand Down