merge: resolve conflicts with main

This commit is contained in:
Jiang Bohan 2026-04-01 13:12:23 +08:00
commit 4780540bd2
121 changed files with 6937 additions and 1556 deletions

View file

@ -0,0 +1,135 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/spf13/cobra"
)
var updateCmd = &cobra.Command{
Use: "update",
Short: "Update multica to the latest version",
RunE: runUpdate,
}
// githubRelease is the subset of the GitHub releases API response we need.
type githubRelease struct {
TagName string `json:"tag_name"`
HTMLURL string `json:"html_url"`
}
func runUpdate(_ *cobra.Command, _ []string) error {
fmt.Fprintf(os.Stderr, "Current version: %s (commit: %s)\n", version, commit)
// Check latest version from GitHub.
latest, err := fetchLatestRelease()
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: could not check latest version: %v\n", err)
} else {
latestVer := strings.TrimPrefix(latest.TagName, "v")
currentVer := strings.TrimPrefix(version, "v")
if currentVer == latestVer {
fmt.Fprintln(os.Stderr, "Already up to date.")
return nil
}
fmt.Fprintf(os.Stderr, "Latest version: %s\n\n", latest.TagName)
}
// Detect installation method and update accordingly.
if isBrewInstall() {
return updateViaBrew()
}
// Not installed via brew — show manual instructions.
fmt.Fprintln(os.Stderr, "multica was not installed via Homebrew.")
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "To install via Homebrew (recommended):")
fmt.Fprintln(os.Stderr, " brew install multica-ai/tap/multica")
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "Or download the latest release from:")
fmt.Fprintln(os.Stderr, " https://github.com/multica-ai/multica/releases/latest")
return nil
}
// isBrewInstall checks whether the running multica binary was installed via Homebrew.
func isBrewInstall() bool {
exePath, err := os.Executable()
if err != nil {
return false
}
// Resolve symlinks (brew links binaries from Cellar into prefix/bin).
resolved, err := filepath.EvalSymlinks(exePath)
if err != nil {
resolved = exePath
}
// Check if the resolved path is inside a Homebrew prefix.
// Common prefixes: /opt/homebrew (Apple Silicon), /usr/local (Intel Mac), or custom.
brewPrefix := getBrewPrefix()
if brewPrefix != "" && strings.HasPrefix(resolved, brewPrefix) {
return true
}
// Fallback: check well-known Homebrew paths.
for _, prefix := range []string{"/opt/homebrew", "/usr/local", "/home/linuxbrew/.linuxbrew"} {
if strings.HasPrefix(resolved, prefix+"/Cellar/") {
return true
}
}
return false
}
// getBrewPrefix returns the Homebrew prefix by running `brew --prefix`, or empty string.
func getBrewPrefix() string {
out, err := exec.Command("brew", "--prefix").Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
func updateViaBrew() error {
fmt.Fprintln(os.Stderr, "Updating via Homebrew...")
cmd := exec.Command("brew", "upgrade", "multica-ai/tap/multica")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("brew upgrade failed: %w\nYou can try manually: brew upgrade multica-ai/tap/multica", err)
}
fmt.Fprintln(os.Stderr, "Update complete.")
return nil
}
func fetchLatestRelease() (*githubRelease, error) {
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequest(http.MethodGet, "https://api.github.com/repos/multica-ai/multica/releases/latest", nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/vnd.github+json")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GitHub API returned %d", resp.StatusCode)
}
var release githubRelease
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return nil, err
}
return &release, nil
}

View file

@ -34,6 +34,7 @@ func init() {
rootCmd.AddCommand(issueCmd)
rootCmd.AddCommand(repoCmd)
rootCmd.AddCommand(versionCmd)
rootCmd.AddCommand(updateCmd)
}
func main() {

View file

@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"log/slog"
"regexp"
"github.com/multica-ai/multica/server/internal/events"
"github.com/multica-ai/multica/server/internal/handler"
@ -13,15 +12,12 @@ import (
"github.com/multica-ai/multica/server/pkg/protocol"
)
// mention represents a parsed @mention from markdown content.
// mention represents a parsed @mention from markdown content (local alias).
type mention struct {
Type string // "member" or "agent"
ID string // user_id or agent_id
}
// mentionRe matches [@Label](mention://type/id) in markdown.
var mentionRe = regexp.MustCompile(`\[@[^\]]*\]\(mention://(member|agent)/([0-9a-fA-F-]+)\)`)
// statusLabels maps DB status values to human-readable labels for notifications.
var statusLabels = map[string]string{
"backlog": "Backlog",
@ -59,17 +55,12 @@ func priorityLabel(p string) string {
var emptyDetails = []byte("{}")
// parseMentions extracts mentions from markdown content.
// Delegates to the shared util.ParseMentions and converts to the local type.
func parseMentions(content string) []mention {
matches := mentionRe.FindAllStringSubmatch(content, -1)
seen := make(map[string]bool)
var result []mention
for _, m := range matches {
key := m[1] + ":" + m[2]
if seen[key] {
continue
}
seen[key] = true
result = append(result, mention{Type: m[1], ID: m[2]})
parsed := util.ParseMentions(content)
result := make([]mention, len(parsed))
for i, m := range parsed {
result[i] = mention{Type: m.Type, ID: m.ID}
}
return result
}

View file

@ -21,8 +21,6 @@ func inboxItemsForRecipient(t *testing.T, queries *db.Queries, recipientID strin
WorkspaceID: util.ParseUUID(testWorkspaceID),
RecipientType: "member",
RecipientID: util.ParseUUID(recipientID),
Limit: 100,
Offset: 0,
})
if err != nil {
t.Fatalf("ListInboxItems: %v", err)

View file

@ -12,11 +12,13 @@ import (
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/multica-ai/multica/server/internal/auth"
"github.com/multica-ai/multica/server/internal/events"
"github.com/multica-ai/multica/server/internal/handler"
"github.com/multica-ai/multica/server/internal/middleware"
"github.com/multica-ai/multica/server/internal/realtime"
"github.com/multica-ai/multica/server/internal/service"
"github.com/multica-ai/multica/server/internal/storage"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
@ -47,7 +49,9 @@ func allowedOrigins() []string {
func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Router {
queries := db.New(pool)
emailSvc := service.NewEmailService()
h := handler.New(queries, pool, hub, bus, emailSvc)
s3 := storage.NewS3StorageFromEnv()
cfSigner := auth.NewCloudFrontSignerFromEnv()
h := handler.New(queries, pool, hub, bus, emailSvc, s3, cfSigner)
r := chi.NewRouter()
@ -79,11 +83,9 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
r.Post("/auth/send-code", h.SendCode)
r.Post("/auth/verify-code", h.VerifyCode)
// Daemon API routes (no user auth; daemon auth deferred to later)
// Daemon API routes (all require a valid token)
r.Route("/api/daemon", func(r chi.Router) {
r.Post("/pairing-sessions", h.CreateDaemonPairingSession)
r.Get("/pairing-sessions/{token}", h.GetDaemonPairingSession)
r.Post("/pairing-sessions/{token}/claim", h.ClaimDaemonPairingSession)
r.Use(middleware.Auth(queries))
r.Post("/register", h.DaemonRegister)
r.Post("/deregister", h.DaemonDeregister)
@ -106,10 +108,12 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
// Protected API routes
r.Group(func(r chi.Router) {
r.Use(middleware.Auth(queries))
r.Use(middleware.RefreshCloudFrontCookies(cfSigner))
// --- User-scoped routes (no workspace context required) ---
r.Get("/api/me", h.GetMe)
r.Patch("/api/me", h.UpdateMe)
r.Post("/api/upload-file", h.UploadFile)
r.Route("/api/workspaces", func(r chi.Router) {
r.Get("/", h.ListWorkspaces)
@ -144,8 +148,6 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
r.Delete("/{id}", h.RevokePersonalAccessToken)
})
r.Post("/api/daemon/pairing-sessions/{token}/approve", h.ApproveDaemonPairingSession)
// --- Workspace-scoped routes (all require workspace membership) ---
r.Group(func(r chi.Router) {
r.Use(middleware.RequireWorkspaceMember(queries))
@ -171,9 +173,13 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
r.Get("/task-runs", h.ListTasksByIssue)
r.Post("/reactions", h.AddIssueReaction)
r.Delete("/reactions", h.RemoveIssueReaction)
r.Get("/attachments", h.ListAttachments)
})
})
// Attachments
r.Delete("/api/attachments/{id}", h.DeleteAttachment)
// Comments
r.Route("/api/comments/{commentId}", func(r chi.Router) {
r.Put("/", h.UpdateComment)

View file

@ -33,9 +33,16 @@ func registerSubscriberListeners(bus *events.Bus, queries *db.Queries) {
!(*issue.AssigneeType == issue.CreatorType && *issue.AssigneeID == issue.CreatorID) {
addSubscriber(bus, queries, e.WorkspaceID, issue.ID, *issue.AssigneeType, *issue.AssigneeID, "assignee")
}
// Subscribe @mentioned users in description
if issue.Description != nil && *issue.Description != "" {
for _, m := range parseMentions(*issue.Description) {
addSubscriber(bus, queries, e.WorkspaceID, issue.ID, m.Type, m.ID, "mentioned")
}
}
})
// issue:updated — subscribe new assignee if assignee changed
// issue:updated — subscribe new assignee or @mentioned users
bus.Subscribe(protocol.EventIssueUpdated, func(e events.Event) {
payload, ok := e.Payload.(map[string]any)
if !ok {
@ -45,13 +52,30 @@ func registerSubscriberListeners(bus *events.Bus, queries *db.Queries) {
if !ok {
return
}
assigneeChanged, _ := payload["assignee_changed"].(bool)
if !assigneeChanged {
return
// Subscribe new assignee if assignee changed
if assigneeChanged, _ := payload["assignee_changed"].(bool); assigneeChanged {
if issue.AssigneeType != nil && issue.AssigneeID != nil {
addSubscriber(bus, queries, e.WorkspaceID, issue.ID, *issue.AssigneeType, *issue.AssigneeID, "assignee")
}
}
if issue.AssigneeType != nil && issue.AssigneeID != nil {
addSubscriber(bus, queries, e.WorkspaceID, issue.ID, *issue.AssigneeType, *issue.AssigneeID, "assignee")
// Subscribe newly @mentioned users in description
if descriptionChanged, _ := payload["description_changed"].(bool); descriptionChanged && issue.Description != nil {
newMentions := parseMentions(*issue.Description)
if len(newMentions) > 0 {
prevMentioned := map[string]bool{}
if prevDescription, _ := payload["prev_description"].(*string); prevDescription != nil {
for _, m := range parseMentions(*prevDescription) {
prevMentioned[m.Type+":"+m.ID] = true
}
}
for _, m := range newMentions {
if !prevMentioned[m.Type+":"+m.ID] {
addSubscriber(bus, queries, e.WorkspaceID, issue.ID, m.Type, m.ID, "mentioned")
}
}
}
}
})