- Add JIRA, Mattermost, Plane adapter implementations - Add Mattermost client tests - Update core server setup and main.go - Improve config and validation tests - Enhance HTTP handlers and middleware tests - Update work item provider and notification tests - Add protoSocket tasks tests - Update agent-task and agent-roadmap documentation
168 lines
4 KiB
Go
168 lines
4 KiB
Go
package mattermost
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
)
|
|
|
|
var (
|
|
ErrMissingConfig = errors.New("mattermost adapter missing required configuration")
|
|
ErrInvalidInput = errors.New("invalid mattermost adapter input")
|
|
)
|
|
|
|
type Config struct {
|
|
BaseURL string
|
|
Token string
|
|
ChannelID string
|
|
}
|
|
|
|
type Client struct {
|
|
cfg Config
|
|
http *http.Client
|
|
logger *slog.Logger
|
|
}
|
|
|
|
type MessageInput struct {
|
|
ChannelID string
|
|
Text string
|
|
}
|
|
|
|
type TaskNotificationInput struct {
|
|
TaskID string
|
|
Title string
|
|
Status string
|
|
Message string
|
|
}
|
|
|
|
func NewClient(cfg Config, logger *slog.Logger) *Client {
|
|
return NewClientWithHTTPClient(cfg, http.DefaultClient, logger)
|
|
}
|
|
|
|
func NewClientWithHTTPClient(cfg Config, httpClient *http.Client, logger *slog.Logger) *Client {
|
|
if httpClient == nil {
|
|
httpClient = http.DefaultClient
|
|
}
|
|
return &Client{cfg: cfg, http: httpClient, logger: logger}
|
|
}
|
|
|
|
func (c *Client) SendMessage(ctx context.Context, input MessageInput) error {
|
|
baseURL, token, err := c.config()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
channelID := strings.TrimSpace(input.ChannelID)
|
|
text := strings.TrimSpace(input.Text)
|
|
if channelID == "" || text == "" {
|
|
return ErrInvalidInput
|
|
}
|
|
|
|
payload, err := json.Marshal(postRequest{
|
|
ChannelID: channelID,
|
|
Message: text,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
endpoint := baseURL + "/api/v4/posts"
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
if c.logger != nil {
|
|
c.logger.Info("mattermost message post requested", "channel_id", channelID)
|
|
}
|
|
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return fmt.Errorf("mattermost API request failed (POST /api/v4/posts): status %d: %s", resp.StatusCode, redactedBody(raw, token))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) SendTaskNotification(ctx context.Context, input TaskNotificationInput) error {
|
|
channelID := strings.TrimSpace(c.cfg.ChannelID)
|
|
if channelID == "" {
|
|
return fmt.Errorf("%w: channel ID", ErrMissingConfig)
|
|
}
|
|
return c.SendMessage(ctx, MessageInput{
|
|
ChannelID: channelID,
|
|
Text: formatTaskNotification(input),
|
|
})
|
|
}
|
|
|
|
type postRequest struct {
|
|
ChannelID string `json:"channel_id"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
func (c *Client) config() (string, string, error) {
|
|
baseURL := strings.TrimRight(strings.TrimSpace(c.cfg.BaseURL), "/")
|
|
token := strings.TrimSpace(c.cfg.Token)
|
|
if baseURL == "" || token == "" {
|
|
return "", "", fmt.Errorf("%w: base URL and token", ErrMissingConfig)
|
|
}
|
|
parsed, err := url.Parse(baseURL)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
if parsed.Scheme == "" || parsed.Host == "" {
|
|
return "", "", fmt.Errorf("mattermost base URL must include scheme and host")
|
|
}
|
|
return baseURL, token, nil
|
|
}
|
|
|
|
func formatTaskNotification(input TaskNotificationInput) string {
|
|
title := strings.TrimSpace(input.Title)
|
|
if title == "" {
|
|
title = strings.TrimSpace(input.TaskID)
|
|
}
|
|
|
|
var lines []string
|
|
if title == "" {
|
|
lines = append(lines, "NomadCode task completed")
|
|
} else {
|
|
lines = append(lines, "NomadCode task completed: "+title)
|
|
}
|
|
if taskID := strings.TrimSpace(input.TaskID); taskID != "" {
|
|
lines = append(lines, "Task ID: "+taskID)
|
|
}
|
|
if status := strings.TrimSpace(input.Status); status != "" {
|
|
lines = append(lines, "Status: "+status)
|
|
}
|
|
if message := strings.TrimSpace(input.Message); message != "" {
|
|
lines = append(lines, "Message: "+message)
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func redactedBody(raw []byte, token string) string {
|
|
message := strings.TrimSpace(string(raw))
|
|
if token != "" {
|
|
message = strings.ReplaceAll(message, token, "<redacted>")
|
|
}
|
|
return message
|
|
}
|