merge: resolve conflicts with main
This commit is contained in:
commit
4780540bd2
121 changed files with 6937 additions and 1556 deletions
135
server/cmd/multica/cmd_update.go
Normal file
135
server/cmd/multica/cmd_update.go
Normal 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
|
||||
}
|
||||
|
|
@ -34,6 +34,7 @@ func init() {
|
|||
rootCmd.AddCommand(issueCmd)
|
||||
rootCmd.AddCommand(repoCmd)
|
||||
rootCmd.AddCommand(versionCmd)
|
||||
rootCmd.AddCommand(updateCmd)
|
||||
}
|
||||
|
||||
func main() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue