This commit is contained in:
ZuoZuo 2026-04-07 12:30:27 +08:00
parent ab0cf8d95c
commit 53629519f5
8 changed files with 496 additions and 203 deletions

View File

@ -111,6 +111,7 @@ func NewRouter(cfg config.Config, manager *service.Manager) *gin.Engine {
api.POST("/releases", handler.saveRelease) api.POST("/releases", handler.saveRelease)
api.GET("/builds", handler.listBuilds) api.GET("/builds", handler.listBuilds)
api.GET("/builds/:id", handler.getBuild)
api.POST("/builds", handler.createBuild) api.POST("/builds", handler.createBuild)
api.GET("/release-runs", handler.listReleaseRuns) api.GET("/release-runs", handler.listReleaseRuns)
@ -287,6 +288,15 @@ func (h *routerHandler) listBuilds(c *gin.Context) {
c.JSON(http.StatusOK, data) c.JSON(http.StatusOK, data)
} }
func (h *routerHandler) getBuild(c *gin.Context) {
data, err := h.manager.GetBuild(c.Request.Context(), uintFromParam(c.Param("id")))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, data)
}
func (h *routerHandler) createBuild(c *gin.Context) { func (h *routerHandler) createBuild(c *gin.Context) {
var payload buildPayload var payload buildPayload
if err := c.ShouldBindJSON(&payload); err != nil { if err := c.ShouldBindJSON(&payload); err != nil {

View File

@ -80,6 +80,7 @@ type BuildJob struct {
COSKey string `gorm:"size:255" json:"cos_key"` COSKey string `gorm:"size:255" json:"cos_key"`
ArtifactURL string `gorm:"size:255" json:"artifact_url"` ArtifactURL string `gorm:"size:255" json:"artifact_url"`
SHA256 string `gorm:"size:128" json:"sha256"` SHA256 string `gorm:"size:128" json:"sha256"`
LogOutput string `gorm:"type:text" json:"log_output"`
LogExcerpt string `gorm:"type:text" json:"log_excerpt"` LogExcerpt string `gorm:"type:text" json:"log_excerpt"`
ErrorMessage string `gorm:"type:text" json:"error_message"` ErrorMessage string `gorm:"type:text" json:"error_message"`
StartedAt *time.Time `json:"started_at"` StartedAt *time.Time `json:"started_at"`

View File

@ -1,10 +1,12 @@
package orchestrator package orchestrator
import ( import (
"bufio"
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"os/exec" "os/exec"
"strings" "strings"
@ -25,6 +27,7 @@ type BuildContext struct {
ServiceName string ServiceName string
ReleaseID string ReleaseID string
Branch string Branch string
OnOutput func(string)
} }
type BuildResult struct { type BuildResult struct {
@ -142,11 +145,36 @@ func (e *ScriptBuildExecutor) Build(ctx context.Context, buildCtx BuildContext)
command.Dir = e.workDir command.Dir = e.workDir
} }
var output bytes.Buffer stdout, err := command.StdoutPipe()
command.Stdout = &output if err != nil {
command.Stderr = &output return result, "", err
}
command.Stderr = command.Stdout
err := command.Run() var output bytes.Buffer
if err := command.Start(); err != nil {
return result, "", err
}
reader := bufio.NewReader(stdout)
for {
chunk, readErr := reader.ReadString('\n')
if chunk != "" {
output.WriteString(chunk)
if buildCtx.OnOutput != nil {
buildCtx.OnOutput(chunk)
}
}
if readErr == nil {
continue
}
if readErr == io.EOF {
break
}
return result, output.String(), readErr
}
err = command.Wait()
raw := output.String() raw := output.String()
for _, line := range strings.Split(raw, "\n") { for _, line := range strings.Split(raw, "\n") {

View File

@ -157,25 +157,71 @@ func (m *Manager) ProcessBuild(ctx context.Context, buildID uint) error {
if err := m.db.WithContext(ctx).Model(&model.BuildJob{}). if err := m.db.WithContext(ctx).Model(&model.BuildJob{}).
Where("id = ?", buildID). Where("id = ?", buildID).
Updates(map[string]any{ Updates(map[string]any{
"status": model.BuildRunning, "status": model.BuildRunning,
"started_at": &startedAt, "started_at": &startedAt,
"log_output": "",
"log_excerpt": "",
"error_message": "",
}).Error; err != nil { }).Error; err != nil {
return err return err
} }
var liveOutput strings.Builder
lastPersistedAt := time.Now()
lastPersistedLen := 0
persistLiveOutput := func(force bool) {
raw := liveOutput.String()
if !force {
if len(raw) == lastPersistedLen {
return
}
if time.Since(lastPersistedAt) < time.Second && len(raw)-lastPersistedLen < 1024 {
return
}
}
if err := m.db.Model(&model.BuildJob{}).
Where("id = ?", buildID).
Updates(map[string]any{
"log_output": raw,
"log_excerpt": trimLog(raw),
}).Error; err != nil {
log.Printf("persist build log %d: %v", buildID, err)
return
}
lastPersistedAt = time.Now()
lastPersistedLen = len(raw)
}
result, output, err := m.buildExecutor.Build(ctx, orchestrator.BuildContext{ result, output, err := m.buildExecutor.Build(ctx, orchestrator.BuildContext{
ServiceName: buildJob.ServiceName, ServiceName: buildJob.ServiceName,
ReleaseID: buildJob.ReleaseID, ReleaseID: buildJob.ReleaseID,
Branch: buildJob.Branch, Branch: buildJob.Branch,
OnOutput: func(chunk string) {
if chunk == "" {
return
}
liveOutput.WriteString(chunk)
persistLiveOutput(false)
},
}) })
rawOutput := output
if rawOutput == "" {
rawOutput = liveOutput.String()
}
if rawOutput != "" && liveOutput.Len() == 0 {
liveOutput.WriteString(rawOutput)
}
persistLiveOutput(true)
finishedAt := time.Now() finishedAt := time.Now()
if err != nil { if err != nil {
return m.db.WithContext(ctx).Model(&model.BuildJob{}). return m.db.WithContext(ctx).Model(&model.BuildJob{}).
Where("id = ?", buildID). Where("id = ?", buildID).
Updates(map[string]any{ Updates(map[string]any{
"status": model.BuildFailed, "status": model.BuildFailed,
"log_excerpt": trimLog(output), "log_output": rawOutput,
"error_message": err.Error(), "log_excerpt": trimLog(rawOutput),
"error_message": summarizeBuildError(err, rawOutput),
"finished_at": &finishedAt, "finished_at": &finishedAt,
}).Error }).Error
} }
@ -193,7 +239,8 @@ func (m *Manager) ProcessBuild(ctx context.Context, buildID uint) error {
"cos_key": result.COSKey, "cos_key": result.COSKey,
"artifact_url": result.ArtifactURL, "artifact_url": result.ArtifactURL,
"sha256": result.SHA256, "sha256": result.SHA256,
"log_excerpt": trimLog(output), "log_output": rawOutput,
"log_excerpt": trimLog(rawOutput),
"finished_at": &finishedAt, "finished_at": &finishedAt,
}).Error; err != nil { }).Error; err != nil {
return err return err
@ -850,6 +897,20 @@ func trimLog(raw string) string {
return raw[len(raw)-maxLen:] return raw[len(raw)-maxLen:]
} }
func summarizeBuildError(err error, output string) string {
lines := strings.Split(output, "\n")
for i := len(lines) - 1; i >= 0; i-- {
line := strings.TrimSpace(lines[i])
if line == "" || strings.HasPrefix(line, "BUILD_RESULT_JSON=") {
continue
}
if line != err.Error() {
return line
}
}
return err.Error()
}
func deploymentLogExcerpt(deployment *model.Deployment) string { func deploymentLogExcerpt(deployment *model.Deployment) string {
if deployment == nil { if deployment == nil {
return "" return ""

View File

@ -30,6 +30,7 @@ export const api = {
listReleases: async (serviceName?: string) => (await client.get<Release[]>('/releases', { params: { service_name: serviceName } })).data, listReleases: async (serviceName?: string) => (await client.get<Release[]>('/releases', { params: { service_name: serviceName } })).data,
saveRelease: async (payload: Partial<Release>) => (await client.post<Release>('/releases', payload)).data, saveRelease: async (payload: Partial<Release>) => (await client.post<Release>('/releases', payload)).data,
listBuilds: async () => (await client.get<BuildJob[]>('/builds')).data, listBuilds: async () => (await client.get<BuildJob[]>('/builds')).data,
getBuild: async (id: number) => (await client.get<BuildJob>(`/builds/${id}`)).data,
createBuild: async (payload: Record<string, unknown>) => (await client.post<BuildJob>('/builds', payload)).data, createBuild: async (payload: Record<string, unknown>) => (await client.post<BuildJob>('/builds', payload)).data,
listReleaseRuns: async () => (await client.get<ReleaseRun[]>('/release-runs')).data, listReleaseRuns: async () => (await client.get<ReleaseRun[]>('/release-runs')).data,
getReleaseRun: async (id: number) => (await client.get<ReleaseRun>(`/release-runs/${id}`)).data, getReleaseRun: async (id: number) => (await client.get<ReleaseRun>(`/release-runs/${id}`)).data,

View File

@ -1,23 +1,26 @@
import { import {
Alert, Alert,
Button, Box,
Card, Button,
CardContent, Card,
Dialog, CardContent,
DialogActions, Chip,
DialogContent, CircularProgress,
DialogTitle, Dialog,
MenuItem, DialogActions,
Snackbar, DialogContent,
Stack, DialogTitle,
Table, MenuItem,
TableBody, Snackbar,
TableCell, Stack,
TableContainer, Table,
TableHead, TableBody,
TableRow, TableCell,
TextField, TableContainer,
Typography TableHead,
TableRow,
TextField,
Typography
} from '@mui/material' } from '@mui/material'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
@ -26,191 +29,353 @@ import StatusChip from '@/components/StatusChip'
import type { BuildJob, CatalogService } from '@/types' import type { BuildJob, CatalogService } from '@/types'
type FormState = { type FormState = {
service_name: string service_name: string
release_id: string release_id: string
branch: string branch: string
operator: string operator: string
} }
const defaultOperator = 'platform-admin' const defaultOperator = 'platform-admin'
const activeBuildStatuses = new Set(['queued', 'running'])
function formatDateTime(value?: string) {
if (!value) return '-'
return new Date(value).toLocaleString()
}
export default function BuildsPage() { export default function BuildsPage() {
const [builds, setBuilds] = useState<BuildJob[]>([]) const [builds, setBuilds] = useState<BuildJob[]>([])
const [services, setServices] = useState<CatalogService[]>([]) const [services, setServices] = useState<CatalogService[]>([])
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [message, setMessage] = useState('') const [detailOpen, setDetailOpen] = useState(false)
const [form, setForm] = useState<FormState>({ const [selectedBuildID, setSelectedBuildID] = useState<number | null>(null)
service_name: '', const [buildDetail, setBuildDetail] = useState<BuildJob | null>(null)
release_id: '', const [detailLoading, setDetailLoading] = useState(false)
branch: 'main', const [message, setMessage] = useState('')
operator: defaultOperator const [error, setError] = useState('')
const [form, setForm] = useState<FormState>({
service_name: '',
release_id: '',
branch: 'main',
operator: defaultOperator
})
const selectedService = useMemo(
() => services.find((service) => service.name === form.service_name),
[form.service_name, services]
)
const selectedBuild = useMemo(() => {
if (buildDetail && buildDetail.id === selectedBuildID) {
return buildDetail
}
return builds.find((build) => build.id === selectedBuildID) ?? null
}, [buildDetail, builds, selectedBuildID])
const resetForm = (catalog: CatalogService[]) => {
const first = catalog[0]
setForm({
service_name: first?.name ?? '',
release_id: '',
branch: first?.default_branch ?? 'main',
operator: defaultOperator
}) })
}
const selectedService = useMemo( const upsertBuild = (build: BuildJob) => {
() => services.find((service) => service.name === form.service_name), setBuilds((prev) => {
[form.service_name, services] const exists = prev.some((item) => item.id === build.id)
) if (!exists) {
return [build, ...prev]
}
return prev.map((item) => (item.id === build.id ? build : item))
})
}
const resetForm = (catalog: CatalogService[]) => { const load = async () => {
const first = catalog[0] const [buildRows, catalog] = await Promise.all([api.listBuilds(), api.listServices()])
setForm({ setBuilds(buildRows)
service_name: first?.name ?? '', setServices(catalog)
release_id: '', if (!form.service_name && catalog.length > 0) {
branch: first?.default_branch ?? 'main', resetForm(catalog)
operator: defaultOperator }
}) }
const loadBuildDetail = async (buildID: number, showLoader = false) => {
if (showLoader) {
setDetailLoading(true)
}
try {
const build = await api.getBuild(buildID)
setBuildDetail(build)
upsertBuild(build)
} catch (err) {
setError(err instanceof Error ? err.message : '加载构建日志失败')
} finally {
if (showLoader) {
setDetailLoading(false)
}
}
}
useEffect(() => {
void load()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
if (!detailOpen || !selectedBuildID) return
void loadBuildDetail(selectedBuildID, true)
if (!selectedBuild || !activeBuildStatuses.has(selectedBuild.status)) {
return
} }
const load = async () => { const timer = window.setInterval(() => {
const [buildRows, catalog] = await Promise.all([api.listBuilds(), api.listServices()]) void loadBuildDetail(selectedBuildID)
setBuilds(buildRows) }, 2000)
setServices(catalog)
if (!form.service_name && catalog.length > 0) { return () => window.clearInterval(timer)
resetForm(catalog) }, [detailOpen, selectedBuildID, selectedBuild?.status])
}
const createBuild = async () => {
try {
const created = await api.createBuild(form)
setOpen(false)
resetForm(services)
setMessage('构建任务已创建,已打开实时构建日志')
setSelectedBuildID(created.id)
setBuildDetail(created)
setDetailOpen(true)
upsertBuild(created)
await load()
} catch (err) {
setError(err instanceof Error ? err.message : '创建构建任务失败')
} }
}
useEffect(() => { const openBuildDetail = (buildID: number) => {
void load() setSelectedBuildID(buildID)
// eslint-disable-next-line react-hooks/exhaustive-deps setBuildDetail(null)
}, []) setDetailOpen(true)
}
const createBuild = async () => { return (
await api.createBuild(form) <Stack spacing={3}>
setOpen(false) <Card>
resetForm(services) <CardContent>
setMessage('构建任务已创建,后台会自动拉取仓库、构建并上传 COS') <Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5}>
await load() <Button
} variant="contained"
onClick={() => {
resetForm(services)
setOpen(true)
}}
>
</Button>
<Button variant="outlined" onClick={() => void load()}>
</Button>
</Stack>
</CardContent>
</Card>
return ( <Card>
<Stack spacing={3}> <CardContent>
<Card> <Typography className="section-title" sx={{ mb: 2 }}>
<CardContent>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5}> </Typography>
<Button <TableContainer>
variant="contained" <Table>
onClick={() => { <TableHead>
resetForm(services) <TableRow>
setOpen(true) <TableCell>ID</TableCell>
}} <TableCell></TableCell>
> <TableCell></TableCell>
<TableCell></TableCell>
</Button> <TableCell></TableCell>
<Button variant="outlined" onClick={() => void load()}> <TableCell></TableCell>
<TableCell></TableCell>
</Button> <TableCell></TableCell>
</Stack> <TableCell align="right"></TableCell>
</CardContent> </TableRow>
</Card> </TableHead>
<TableBody>
{builds.map((row) => (
<TableRow key={row.id}>
<TableCell>{row.id}</TableCell>
<TableCell>{row.service_name}</TableCell>
<TableCell>{row.release_id}</TableCell>
<TableCell>{row.branch}</TableCell>
<TableCell>
<StatusChip status={row.status} />
</TableCell>
<TableCell>{row.commit_sha || '-'}</TableCell>
<TableCell>{row.build_host || '-'}</TableCell>
<TableCell sx={{ maxWidth: 360 }}>
<Typography variant="body2" color="text.secondary" sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{row.error_message || '-'}
</Typography>
</TableCell>
<TableCell align="right">
<Button size="small" onClick={() => openBuildDetail(row.id)}>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</CardContent>
</Card>
<Card> <Dialog open={open} onClose={() => setOpen(false)} fullWidth maxWidth="sm">
<CardContent> <DialogTitle></DialogTitle>
<Typography className="section-title" sx={{ mb: 2 }}> <DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
</Typography> <TextField
<TableContainer> select
<Table> label="微服务"
<TableHead> value={form.service_name}
<TableRow> onChange={(event) => {
<TableCell>ID</TableCell> const serviceName = event.target.value
<TableCell></TableCell> const serviceMeta = services.find((item) => item.name === serviceName)
<TableCell></TableCell> setForm((prev) => ({
<TableCell></TableCell> ...prev,
<TableCell></TableCell> service_name: serviceName,
<TableCell></TableCell> branch: serviceMeta?.default_branch ?? prev.branch
<TableCell></TableCell> }))
<TableCell>COS Key</TableCell> }}
</TableRow> >
</TableHead> {services.map((service) => (
<TableBody> <MenuItem key={service.name} value={service.name}>
{builds.map((row) => ( {service.name}
<TableRow key={row.id}> </MenuItem>
<TableCell>{row.id}</TableCell> ))}
<TableCell>{row.service_name}</TableCell> </TextField>
<TableCell>{row.release_id}</TableCell> <TextField
<TableCell>{row.branch}</TableCell> label="Release ID"
<TableCell> placeholder="例如 20260407-abc1234"
<StatusChip status={row.status} /> value={form.release_id}
</TableCell> onChange={(event) => setForm((prev) => ({ ...prev, release_id: event.target.value }))}
<TableCell>{row.commit_sha || '-'}</TableCell> />
<TableCell>{row.build_host || '-'}</TableCell> <TextField
<TableCell sx={{ maxWidth: 420, wordBreak: 'break-all' }}>{row.cos_key || '-'}</TableCell> label="分支"
</TableRow> value={form.branch}
))} onChange={(event) => setForm((prev) => ({ ...prev, branch: event.target.value }))}
</TableBody> />
</Table> <TextField
</TableContainer> label="操作人"
</CardContent> value={form.operator}
</Card> onChange={(event) => setForm((prev) => ({ ...prev, operator: event.target.value }))}
/>
<TextField label="仓库" value={selectedService?.repo ?? ''} slotProps={{ input: { readOnly: true } }} />
<Typography variant="body2" color="text.secondary">
`git fetch / checkout / pull`
</Typography>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpen(false)}></Button>
<Button
variant="contained"
disabled={!form.service_name || !form.release_id || !form.branch}
onClick={() => void createBuild()}
>
</Button>
</DialogActions>
</Dialog>
<Dialog open={open} onClose={() => setOpen(false)} fullWidth maxWidth="sm"> <Dialog open={detailOpen} onClose={() => setDetailOpen(false)} fullWidth maxWidth="lg">
<DialogTitle></DialogTitle> <DialogTitle></DialogTitle>
<DialogContent> <DialogContent dividers>
<Stack spacing={2} sx={{ mt: 1 }}> {detailLoading && !selectedBuild ? (
<TextField <Stack alignItems="center" justifyContent="center" sx={{ py: 8 }}>
select <CircularProgress size={28} />
label="微服务" </Stack>
value={form.service_name} ) : selectedBuild ? (
onChange={(event) => { <Stack spacing={2}>
const serviceName = event.target.value <Stack direction={{ xs: 'column', lg: 'row' }} spacing={1.5} justifyContent="space-between">
const serviceMeta = services.find((item) => item.name === serviceName) <Stack spacing={1}>
setForm((prev) => ({ <Typography variant="h6">{`${selectedBuild.service_name} · ${selectedBuild.release_id}`}</Typography>
...prev, <Stack direction="row" spacing={1} useFlexGap flexWrap="wrap">
service_name: serviceName, <Chip size="small" variant="outlined" label={`分支 ${selectedBuild.branch || '-'}`} />
branch: serviceMeta?.default_branch ?? prev.branch <Chip size="small" variant="outlined" label={`提交 ${selectedBuild.commit_sha || '-'}`} />
})) <Chip size="small" variant="outlined" label={`构建机 ${selectedBuild.build_host || '-'}`} />
}} <Chip size="small" variant="outlined" label={`开始 ${formatDateTime(selectedBuild.started_at || selectedBuild.created_at)}`} />
> <Chip size="small" variant="outlined" label={`结束 ${formatDateTime(selectedBuild.finished_at)}`} />
{services.map((service) => ( </Stack>
<MenuItem key={service.name} value={service.name}> </Stack>
{service.name} <Stack direction="row" spacing={1} alignItems="center">
</MenuItem> <StatusChip status={selectedBuild.status} />
))} <Button variant="outlined" onClick={() => selectedBuildID && void loadBuildDetail(selectedBuildID, true)}>
</TextField>
<TextField </Button>
label="Release ID" </Stack>
placeholder="例如 20260407-abc1234" </Stack>
value={form.release_id}
onChange={(event) => setForm((prev) => ({ ...prev, release_id: event.target.value }))}
/>
<TextField
label="分支"
value={form.branch}
onChange={(event) => setForm((prev) => ({ ...prev, branch: event.target.value }))}
/>
<TextField
label="操作人"
value={form.operator}
onChange={(event) => setForm((prev) => ({ ...prev, operator: event.target.value }))}
/>
<TextField
label="仓库"
value={selectedService?.repo ?? ''}
slotProps={{ input: { readOnly: true } }}
/>
<Typography variant="body2" color="text.secondary">
`git fetch / checkout / pull`
</Typography>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpen(false)}></Button>
<Button
variant="contained"
disabled={!form.service_name || !form.release_id || !form.branch}
onClick={() => void createBuild()}
>
</Button>
</DialogActions>
</Dialog>
<Snackbar open={Boolean(message)} autoHideDuration={2600} onClose={() => setMessage('')}> {activeBuildStatuses.has(selectedBuild.status) ? (
<Alert severity="success" variant="filled"> <Alert severity="info" variant="outlined">
{message} 2
</Alert> </Alert>
</Snackbar> ) : null}
</Stack>
) {selectedBuild.error_message ? (
<Alert severity="error" variant="filled">
{selectedBuild.error_message}
</Alert>
) : null}
<Box
component="pre"
sx={{
m: 0,
p: 2,
maxHeight: 560,
overflow: 'auto',
borderRadius: 3,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
fontSize: 13,
lineHeight: 1.7,
fontFamily: '"SFMono-Regular", "Roboto Mono", "Menlo", monospace',
backgroundColor: 'rgba(16,24,40,0.98)',
color: 'rgb(231, 239, 255)'
}}
>
{selectedBuild.log_output || selectedBuild.log_excerpt || '暂无日志输出'}
</Box>
<Typography variant="body2" color="text.secondary" sx={{ wordBreak: 'break-all' }}>
COS Key: {selectedBuild.cos_key || '-'}
</Typography>
</Stack>
) : (
<Alert severity="warning" variant="outlined">
</Alert>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setDetailOpen(false)}></Button>
</DialogActions>
</Dialog>
<Snackbar open={Boolean(message)} autoHideDuration={2600} onClose={() => setMessage('')}>
<Alert severity="success" variant="filled">
{message}
</Alert>
</Snackbar>
<Snackbar open={Boolean(error)} autoHideDuration={4000} onClose={() => setError('')}>
<Alert severity="error" variant="filled">
{error}
</Alert>
</Snackbar>
</Stack>
)
} }

View File

@ -51,6 +51,7 @@ export interface BuildJob {
cos_key: string cos_key: string
artifact_url: string artifact_url: string
sha256: string sha256: string
log_output: string
log_excerpt: string log_excerpt: string
error_message: string error_message: string
created_at?: string created_at?: string

View File

@ -47,6 +47,18 @@ def normalize_remote(remote: str) -> str:
return value.strip("/").lower() return value.strip("/").lower()
def git_host(remote: str) -> str:
value = remote.strip()
if not value:
return ""
if value.startswith("git@") and ":" in value:
return value.split("@", 1)[1].split(":", 1)[0].strip().lower()
parsed = urlparse(value)
if parsed.netloc:
return parsed.netloc.strip().lower()
return ""
def run_command( def run_command(
argv: list[str], argv: list[str],
*, *,
@ -55,19 +67,29 @@ def run_command(
allow_failure: bool = False, allow_failure: bool = False,
) -> subprocess.CompletedProcess[str]: ) -> subprocess.CompletedProcess[str]:
log(f"run: {' '.join(argv)}") log(f"run: {' '.join(argv)}")
proc = subprocess.run( proc = subprocess.Popen(
argv, argv,
cwd=str(cwd) if cwd else None, cwd=str(cwd) if cwd else None,
env=env, env=env,
text=True, text=True,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, stderr=subprocess.STDOUT,
bufsize=1,
) )
if proc.stdout:
print(proc.stdout, end="") output_parts: list[str] = []
if proc.returncode != 0 and not allow_failure: assert proc.stdout is not None
raise RuntimeError(f"command failed ({proc.returncode}): {' '.join(argv)}") for line in proc.stdout:
return proc output_parts.append(line)
print(line, end="", flush=True)
returncode = proc.wait()
stdout = "".join(output_parts)
completed = subprocess.CompletedProcess(argv, returncode, stdout)
if returncode != 0 and not allow_failure:
raise RuntimeError(f"command failed ({returncode}): {' '.join(argv)}")
return completed
class BuildOperator: class BuildOperator:
@ -158,6 +180,10 @@ class BuildOperator:
env["CGO_ENABLED"] = "0" env["CGO_ENABLED"] = "0"
env["GOOS"] = "linux" env["GOOS"] = "linux"
env["GOARCH"] = "amd64" env["GOARCH"] = "amd64"
private_host = git_host(self.repo_url) or git_host(str(self.global_build_cfg.get("gitea_clone_prefix", "")))
if private_host:
env.setdefault("GOPRIVATE", private_host)
env.setdefault("GONOSUMDB", private_host)
if self.git_ssh_command: if self.git_ssh_command:
env["GIT_SSH_COMMAND"] = self.git_ssh_command env["GIT_SSH_COMMAND"] = self.git_ssh_command
return env return env