mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
feat(git): push webhook receiver + repo connect on service create
New public POST /api/git/webhooks/🆔 verifies provider signatures (GitHub/Gitea HMAC-SHA256, GitLab token), filters branch pushes, matches connected repo to services by clone_url/full_name + branch, inserts deployment rows and enqueues runDeploymentAndSync (trigger=webhook). Service-create dialog now connects the selected provider repo and registers a push webhook (409-tolerant via connected-repo lookup). api-client: connectGitRepository, listConnectedGitRepositories, createGitWebhook typed from generated schemas. GitWebhook spec schema corrected (events array + branch field). Authored By: TDvorak <[email protected]>
This commit is contained in:
1 parent
93e3074477
commit
25703592c6
8 files changed
+550
-11
No files matched your search
+11
-6
@@ -196,12 +196,17 @@ OpenAPI spec matches the router 1:1; zero dead packages.
|
|||||||
The product's spine: *project → service → deploy → logs → rollback*. Most
|
The product's spine: *project → service → deploy → logs → rollback*. Most
|
||||||
pieces exist; this phase closes the gaps that block real usage.
|
pieces exist; this phase closes the gaps that block real usage.
|
||||||
|
|
||||||
- [ ] **Git connect flow, end to end**: Settings → provider → repo picker →
|
- [x] **Git connect flow, end to end**: Settings → GitProvidersSection manages
|
||||||
branch picker → root dir → creates service + webhook. Backend exists;
|
providers; service dialog picks provider → repo → branch; on create the
|
||||||
build the UI and verify a real push triggers a build+deploy.
|
repo is connected (`POST /git/repositories/connect`, 409-tolerant) and a
|
||||||
- [ ] **Webhook auto-deploy**: `POST /git/webhooks` receiver, verify signature,
|
push webhook is registered (`POST /git/webhooks`). Live provider push
|
||||||
map repo+branch → service, enqueue build, surface status on canvas node
|
verification pending real credentials.
|
||||||
and `/builds`. Close the loop described in `AUTO_DEPLOY_GUIDE.md`.
|
- [x] **Webhook auto-deploy**: public receiver `POST /api/git/webhooks/:id`
|
||||||
|
verifies HMAC (GitHub `X-Hub-Signature-256`, Gitea `X-Gitea-Signature`)
|
||||||
|
or GitLab `X-Gitlab-Token`, maps repo+branch → services, inserts
|
||||||
|
deployment rows and enqueues `runDeploymentAndSync` (trigger=webhook),
|
||||||
|
which flips canvas node status to building. Unit-tested signature
|
||||||
|
verification. Not yet e2e-verified against a real provider.
|
||||||
- [ ] **Compose template catalog** per `docs/superpowers/specs/2026-04-14-compose-template-catalog-design.md`:
|
- [ ] **Compose template catalog** per `docs/superpowers/specs/2026-04-14-compose-template-catalog-design.md`:
|
||||||
- `service_templates.compose_yaml`, `screenshots`, `source_type`,
|
- `service_templates.compose_yaml`, `screenshots`, `source_type`,
|
||||||
parsed `config` summary (migration + sqlc).
|
parsed `config` summary (migration + sqlc).
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"containr/internal/database"
|
||||||
|
"containr/internal/deployment"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleGitWebhookPush is the public push receiver. Providers call
|
||||||
|
// POST /api/git/webhooks/:id with a signed payload; the HMAC against
|
||||||
|
// git_webhooks.webhook_secret replaces session auth.
|
||||||
|
func handleGitWebhookPush(c *gin.Context) {
|
||||||
|
dbValue, exists := c.Get("db")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db := dbValue.(*database.DB)
|
||||||
|
|
||||||
|
webhookID := strings.TrimSpace(c.Param("id"))
|
||||||
|
if _, err := uuid.Parse(webhookID); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid webhook ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
secret string
|
||||||
|
repoID string
|
||||||
|
providerID string
|
||||||
|
branchFilter string
|
||||||
|
active bool
|
||||||
|
)
|
||||||
|
err := db.QueryRow(`
|
||||||
|
SELECT webhook_secret, repo_id, provider_id, COALESCE(branch_filter, ''), active
|
||||||
|
FROM git_webhooks WHERE id = $1`, webhookID).
|
||||||
|
Scan(&secret, &repoID, &providerID, &branchFilter, &active)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Webhook not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !active {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Webhook is disabled"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(io.LimitReader(c.Request.Body, 4<<20))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read payload"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var providerName string
|
||||||
|
_ = db.QueryRow(`SELECT name FROM git_providers WHERE id = $1`, providerID).Scan(&providerName)
|
||||||
|
|
||||||
|
if !verifyGitWebhookSignature(providerName, secret, c.Request, body) {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid signature"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload struct {
|
||||||
|
Ref string `json:"ref"`
|
||||||
|
After string `json:"after"`
|
||||||
|
Repository struct {
|
||||||
|
FullName string `json:"full_name"`
|
||||||
|
PathWithNamespace string `json:"path_with_namespace"`
|
||||||
|
} `json:"repository"`
|
||||||
|
Project struct {
|
||||||
|
PathWithNamespace string `json:"path_with_namespace"`
|
||||||
|
} `json:"project"`
|
||||||
|
HeadCommit struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"head_commit"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &payload); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasPrefix(payload.Ref, "refs/heads/") {
|
||||||
|
c.JSON(http.StatusAccepted, gin.H{"received": true, "ignored": "not a branch push"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
branch := strings.TrimPrefix(payload.Ref, "refs/heads/")
|
||||||
|
if branchFilter != "" && branchFilter != branch {
|
||||||
|
c.JSON(http.StatusAccepted, gin.H{"received": true, "ignored": "branch not watched"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
commit := payload.HeadCommit.ID
|
||||||
|
if commit == "" {
|
||||||
|
commit = payload.After
|
||||||
|
}
|
||||||
|
|
||||||
|
var cloneURL, fullName, repoUserID string
|
||||||
|
err = db.QueryRow(
|
||||||
|
`SELECT clone_url, full_name, user_id FROM git_repositories WHERE id = $1`, repoID,
|
||||||
|
).Scan(&cloneURL, &fullName, &repoUserID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Repository not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := db.Query(`
|
||||||
|
SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command,
|
||||||
|
s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory,
|
||||||
|
s.created_at, s.updated_at
|
||||||
|
FROM services s
|
||||||
|
WHERE s.git_branch = $1 AND (s.git_repo = $2 OR s.git_repo = $3)`,
|
||||||
|
branch, cloneURL, fullName)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to match services"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var services []Service
|
||||||
|
for rows.Next() {
|
||||||
|
var s Service
|
||||||
|
if err := rows.Scan(
|
||||||
|
&s.ID, &s.ProjectID, &s.Name, &s.Type, &s.Status, &s.Image, &s.Command,
|
||||||
|
&s.Environment, &s.GitRepo, &s.GitBranch, &s.BuildPath, &s.CPU, &s.Memory,
|
||||||
|
&s.CreatedAt, &s.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan service"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
services = append(services, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
engineValue, _ := c.Get("deployment_engine")
|
||||||
|
engine, _ := engineValue.(*deployment.DeploymentEngine)
|
||||||
|
|
||||||
|
enqueued := 0
|
||||||
|
for _, service := range services {
|
||||||
|
now := time.Now()
|
||||||
|
var commitHash *string
|
||||||
|
if commit != "" {
|
||||||
|
commitHash = &commit
|
||||||
|
}
|
||||||
|
d := DeploymentModel{
|
||||||
|
ID: uuid.New(),
|
||||||
|
ServiceID: service.ID,
|
||||||
|
CommitHash: commitHash,
|
||||||
|
Status: "pending",
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(
|
||||||
|
`INSERT INTO deployments
|
||||||
|
(id, service_id, commit_hash, status, image_name, image_tag, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||||
|
d.ID, d.ServiceID, d.CommitHash, d.Status, d.ImageName, d.ImageTag, d.CreatedAt, d.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if engine == nil {
|
||||||
|
failedAt := time.Now()
|
||||||
|
failure := "Deployment engine unavailable. Docker may not be configured on this server."
|
||||||
|
_, _ = db.Exec(
|
||||||
|
`UPDATE deployments SET status = 'failed', error = $1, completed_at = $2, updated_at = $2 WHERE id = $3`,
|
||||||
|
failure, failedAt, d.ID,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, _ = db.Exec(
|
||||||
|
`UPDATE services SET status = 'building', updated_at = $1 WHERE id = $2`,
|
||||||
|
time.Now(), service.ID,
|
||||||
|
)
|
||||||
|
go runDeploymentAndSync(
|
||||||
|
context.Background(), db, engine, &d, service,
|
||||||
|
CreateDeploymentRequest{CommitHash: commit, Branch: branch, Trigger: "webhook"},
|
||||||
|
repoUserID,
|
||||||
|
)
|
||||||
|
enqueued++
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusAccepted, gin.H{
|
||||||
|
"received": true,
|
||||||
|
"branch": branch,
|
||||||
|
"deployments": enqueued,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifyGitWebhookSignature checks the push signature per provider:
|
||||||
|
// GitHub X-Hub-Signature-256 and Gitea X-Gitea-Signature are HMAC-SHA256 hex;
|
||||||
|
// GitLab compares X-Gitlab-Token to the stored secret directly.
|
||||||
|
func verifyGitWebhookSignature(provider, secret string, r *http.Request, body []byte) bool {
|
||||||
|
if strings.EqualFold(provider, "gitlab") {
|
||||||
|
token := r.Header.Get("X-Gitlab-Token")
|
||||||
|
return token != "" && subtle.ConstantTimeCompare([]byte(token), []byte(secret)) == 1
|
||||||
|
}
|
||||||
|
mac := hmac.New(sha256.New, []byte(secret))
|
||||||
|
mac.Write(body)
|
||||||
|
sum := mac.Sum(nil)
|
||||||
|
for _, header := range []string{"X-Hub-Signature-256", "X-Gitea-Signature"} {
|
||||||
|
sig := strings.TrimPrefix(r.Header.Get(header), "sha256=")
|
||||||
|
if decoded, err := hex.DecodeString(sig); err == nil && len(decoded) > 0 && hmac.Equal(decoded, sum) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func signBody(t *testing.T, secret string, body []byte) string {
|
||||||
|
t.Helper()
|
||||||
|
mac := hmac.New(sha256.New, []byte(secret))
|
||||||
|
mac.Write(body)
|
||||||
|
return hex.EncodeToString(mac.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyGitWebhookSignature(t *testing.T) {
|
||||||
|
body := []byte(`{"ref":"refs/heads/main"}`)
|
||||||
|
secret := "test-secret"
|
||||||
|
valid := signBody(t, secret, body)
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
provider string
|
||||||
|
headers map[string]string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "github sha256 prefix",
|
||||||
|
provider: "github",
|
||||||
|
headers: map[string]string{"X-Hub-Signature-256": "sha256=" + valid},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gitea signature",
|
||||||
|
provider: "gitea",
|
||||||
|
headers: map[string]string{"X-Gitea-Signature": valid},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gitlab token",
|
||||||
|
provider: "gitlab",
|
||||||
|
headers: map[string]string{"X-Gitlab-Token": secret},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gitlab rejects wrong token",
|
||||||
|
provider: "gitlab",
|
||||||
|
headers: map[string]string{"X-Gitlab-Token": "wrong"},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "github rejects wrong signature",
|
||||||
|
provider: "github",
|
||||||
|
headers: map[string]string{"X-Hub-Signature-256": "sha256=" + signBody(t, "other", body)},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing signature",
|
||||||
|
provider: "github",
|
||||||
|
headers: map[string]string{},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "malformed hex",
|
||||||
|
provider: "github",
|
||||||
|
headers: map[string]string{"X-Hub-Signature-256": "sha256=zzz"},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest("POST", "/api/git/webhooks/x", nil)
|
||||||
|
for k, v := range tc.headers {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
if got := verifyGitWebhookSignature(tc.provider, secret, req, body); got != tc.want {
|
||||||
|
t.Fatalf("verifyGitWebhookSignature = %v, want %v", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -152,6 +152,10 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
publicAgents := router.Group("/api")
|
publicAgents := router.Group("/api")
|
||||||
agentHandler.SetupPublicRoutes(publicAgents)
|
agentHandler.SetupPublicRoutes(publicAgents)
|
||||||
|
|
||||||
|
// Public push receiver: HMAC signature on git_webhooks.webhook_secret
|
||||||
|
// replaces session auth. Providers POST to /api/git/webhooks/:id.
|
||||||
|
router.POST("/api/git/webhooks/:id", handleGitWebhookPush)
|
||||||
|
|
||||||
v1 := router.Group("/api/v1")
|
v1 := router.Group("/api/v1")
|
||||||
{
|
{
|
||||||
// Public routes (no authentication required)
|
// Public routes (no authentication required)
|
||||||
|
|||||||
@@ -2,8 +2,12 @@ import { useEffect, useMemo, useState } from 'react';
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
|
ApiError,
|
||||||
|
connectGitRepository,
|
||||||
|
createGitWebhook,
|
||||||
createService,
|
createService,
|
||||||
getProjectById,
|
getProjectById,
|
||||||
|
listConnectedGitRepositories,
|
||||||
listGitBranches,
|
listGitBranches,
|
||||||
listGitProviders,
|
listGitProviders,
|
||||||
listGitRepositories,
|
listGitRepositories,
|
||||||
@@ -50,10 +54,15 @@ const viewItems: Array<{ key: WorkspaceView; label: string; icon: typeof LayoutG
|
|||||||
const serviceTypes: Array<CreateServiceInput['type']> = ['web', 'worker', 'database', 'cron'];
|
const serviceTypes: Array<CreateServiceInput['type']> = ['web', 'worker', 'database', 'cron'];
|
||||||
const serviceEnvironments = ['production', 'preview', 'development'] as const;
|
const serviceEnvironments = ['production', 'preview', 'development'] as const;
|
||||||
|
|
||||||
|
type CreateServiceSubmit = CreateServiceInput & {
|
||||||
|
gitProviderId?: string;
|
||||||
|
gitRepoFullName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
function ServiceCreateDialog(props: {
|
function ServiceCreateDialog(props: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSubmit: (payload: CreateServiceInput) => void;
|
onSubmit: (payload: CreateServiceSubmit) => void;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
}) {
|
}) {
|
||||||
@@ -365,6 +374,8 @@ function ServiceCreateDialog(props: {
|
|||||||
git_repo: source === 'git' ? form.git_repo?.trim() : '',
|
git_repo: source === 'git' ? form.git_repo?.trim() : '',
|
||||||
git_branch: source === 'git' ? form.git_branch?.trim() : '',
|
git_branch: source === 'git' ? form.git_branch?.trim() : '',
|
||||||
build_path: source === 'git' ? form.build_path?.trim() : '',
|
build_path: source === 'git' ? form.build_path?.trim() : '',
|
||||||
|
gitProviderId: source === 'git' && repoFullName ? providerId : undefined,
|
||||||
|
gitRepoFullName: source === 'git' ? repoFullName || undefined : undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="px-5 py-2 rounded-[var(--radius-md)] text-[var(--accent-on)] text-sm font-medium shadow-lg disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
className="px-5 py-2 rounded-[var(--radius-md)] text-[var(--accent-on)] text-sm font-medium shadow-lg disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
||||||
@@ -416,7 +427,35 @@ export function ProjectWorkspacePage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const createServiceMutation = useMutation({
|
const createServiceMutation = useMutation({
|
||||||
mutationFn: (payload: CreateServiceInput) => createService(projectId, payload),
|
mutationFn: async (payload: CreateServiceSubmit) => {
|
||||||
|
const { gitProviderId, gitRepoFullName, ...serviceInput } = payload;
|
||||||
|
const service = await createService(projectId, serviceInput);
|
||||||
|
if (gitProviderId && gitRepoFullName) {
|
||||||
|
let repoId: string | undefined;
|
||||||
|
try {
|
||||||
|
const repo = await connectGitRepository({
|
||||||
|
provider_id: gitProviderId,
|
||||||
|
repo_full_name: gitRepoFullName,
|
||||||
|
});
|
||||||
|
repoId = repo.id;
|
||||||
|
} catch (err) {
|
||||||
|
if (!(err instanceof ApiError && err.status === 409)) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
repoId = (await listConnectedGitRepositories()).find(
|
||||||
|
(r) => r.full_name === gitRepoFullName,
|
||||||
|
)?.id;
|
||||||
|
}
|
||||||
|
if (repoId) {
|
||||||
|
await createGitWebhook({
|
||||||
|
repo_id: repoId,
|
||||||
|
events: ['push'],
|
||||||
|
branch: payload.git_branch ?? '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return service;
|
||||||
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setCreateOpen(false);
|
setCreateOpen(false);
|
||||||
queryClient.invalidateQueries({ queryKey: ['project-services', projectId] });
|
queryClient.invalidateQueries({ queryKey: ['project-services', projectId] });
|
||||||
|
|||||||
@@ -4748,6 +4748,92 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/git/webhooks/{id}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Push event receiver
|
||||||
|
* @description Public provider webhook endpoint. The HMAC signature against the stored webhook secret replaces session auth: GitHub X-Hub-Signature-256, Gitea X-Gitea-Signature, GitLab X-Gitlab-Token. Matching branch pushes enqueue deployments for connected services.
|
||||||
|
*/
|
||||||
|
post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": Record<string, never>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Push accepted; matched services queued for deployment */
|
||||||
|
202: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
received?: boolean;
|
||||||
|
branch?: string;
|
||||||
|
deployments?: number;
|
||||||
|
ignored?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Invalid webhook ID or payload */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["Error"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Invalid signature */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["Error"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Webhook disabled */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["Error"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Webhook or repository not found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["Error"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/agents/{id}/commands/{commandId}/result": {
|
"/api/agents/{id}/commands/{commandId}/result": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -7014,8 +7100,10 @@ export interface components {
|
|||||||
id?: string;
|
id?: string;
|
||||||
repo_id?: string;
|
repo_id?: string;
|
||||||
provider_id?: string;
|
provider_id?: string;
|
||||||
/** @description JSON-encoded array of subscribed events */
|
/** @description Subscribed provider events */
|
||||||
events?: string;
|
events?: string[];
|
||||||
|
/** @description Branch filter for push events */
|
||||||
|
branch?: string;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
|
|||||||
@@ -1230,3 +1230,36 @@ export async function listGitBranches(
|
|||||||
);
|
);
|
||||||
return payload.branches ?? [];
|
return payload.branches ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ConnectGitRepositoryInput = components['schemas']['ConnectGitRepoRequest'];
|
||||||
|
export type CreateGitWebhookInput = components['schemas']['CreateWebhookRequest'];
|
||||||
|
export type GitWebhookEntity = components['schemas']['GitWebhook'];
|
||||||
|
|
||||||
|
export async function connectGitRepository(
|
||||||
|
input: ConnectGitRepositoryInput,
|
||||||
|
): Promise<GitRepositoryEntity> {
|
||||||
|
const payload = await requestJson<{ repository?: GitRepositoryEntity }>(
|
||||||
|
'/git/repositories/connect',
|
||||||
|
{ method: 'POST', body: JSON.stringify(input) },
|
||||||
|
);
|
||||||
|
if (!payload.repository?.id) {
|
||||||
|
throw new ApiError('Connect repository response is invalid', 500);
|
||||||
|
}
|
||||||
|
return payload.repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listConnectedGitRepositories(): Promise<GitRepositoryEntity[]> {
|
||||||
|
const payload = await requestJson<{ repositories?: GitRepositoryEntity[] }>('/git/repositories');
|
||||||
|
return payload.repositories ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createGitWebhook(input: CreateGitWebhookInput): Promise<GitWebhookEntity> {
|
||||||
|
const payload = await requestJson<{ webhook?: GitWebhookEntity }>('/git/webhooks', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
if (!payload.webhook?.id) {
|
||||||
|
throw new ApiError('Webhook response is invalid', 500);
|
||||||
|
}
|
||||||
|
return payload.webhook;
|
||||||
|
}
|
||||||
+73
-1
@@ -2919,6 +2919,73 @@ paths:
|
|||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/AgentCommand'
|
$ref: '#/components/schemas/AgentCommand'
|
||||||
|
|
||||||
|
/api/git/webhooks/{id}:
|
||||||
|
servers:
|
||||||
|
- url: http://localhost:8080
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Git
|
||||||
|
summary: Push event receiver
|
||||||
|
description: >-
|
||||||
|
Public provider webhook endpoint. The HMAC signature against the stored
|
||||||
|
webhook secret replaces session auth: GitHub X-Hub-Signature-256,
|
||||||
|
Gitea X-Gitea-Signature, GitLab X-Gitlab-Token. Matching branch pushes
|
||||||
|
enqueue deployments for connected services.
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
description: Provider push payload (ref, repository, head_commit)
|
||||||
|
responses:
|
||||||
|
'202':
|
||||||
|
description: Push accepted; matched services queued for deployment
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
received:
|
||||||
|
type: boolean
|
||||||
|
branch:
|
||||||
|
type: string
|
||||||
|
deployments:
|
||||||
|
type: integer
|
||||||
|
ignored:
|
||||||
|
type: string
|
||||||
|
'400':
|
||||||
|
description: Invalid webhook ID or payload
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
'401':
|
||||||
|
description: Invalid signature
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
'403':
|
||||||
|
description: Webhook disabled
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
'404':
|
||||||
|
description: Webhook or repository not found
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
|
||||||
/api/agents/{id}/commands/{commandId}/result:
|
/api/agents/{id}/commands/{commandId}/result:
|
||||||
servers:
|
servers:
|
||||||
- url: http://localhost:8080
|
- url: http://localhost:8080
|
||||||
@@ -4984,8 +5051,13 @@ components:
|
|||||||
provider_id:
|
provider_id:
|
||||||
type: string
|
type: string
|
||||||
events:
|
events:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
description: Subscribed provider events
|
||||||
|
branch:
|
||||||
type: string
|
type: string
|
||||||
description: JSON-encoded array of subscribed events
|
description: Branch filter for push events
|
||||||
active:
|
active:
|
||||||
type: boolean
|
type: boolean
|
||||||
created_at:
|
created_at:
|
||||||
|
|||||||
Reference in new issue
Block a user