/**
* This file is part of the raoptimus/evateamclient.go library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/evateamclient.go/blob/master/LICENSE.md
* @link https://github.com/raoptimus/evateamclient.go
*/
package evateamclient
import (
"context"
"net/url"
"time"
"github.com/imroc/req/v3"
jsoniter "github.com/json-iterator/go"
"github.com/pkg/errors"
)
const (
defaultTimeout = 30 * time.Second
basePath = "/api/"
)
var json = jsoniter.ConfigCompatibleWithStandardLibrary
type HTTPClient interface {
Post(ctx context.Context, body []byte, url string) (*req.Response, error)
}
type httpClient struct {
hc *req.Client
}
func (h *httpClient) Post(ctx context.Context, body []byte, url string) (*req.Response, error) {
return h.hc.R().
SetContext(ctx).
SetBodyBytes(body).
Post(url)
}
// Client is the EVA Team API client
type Client struct {
metrics Metrics
baseURL *url.URL
apiToken string
httpClient HTTPClient
logger Logger
debug bool
}
// Config holds client configuration
type Config struct {
BaseURL string
APIToken string
Debug bool
Timeout time.Duration
}
type Option func(*Client)
func WithLogger(l Logger) Option {
return func(c *Client) {
c.logger = l
}
}
func WithDebug(debug bool) Option {
return func(c *Client) {
c.debug = debug
}
}
func WithMetrics(m Metrics) Option {
return func(c *Client) {
c.metrics = m
}
}
func NewClient(cfg *Config, opts ...Option) (*Client, error) {
if cfg.BaseURL == "" {
return nil, errors.WithMessage(errors.WithStack(ErrOptionIsRequired), "baseURL")
}
baseURL, err := url.Parse(cfg.BaseURL)
if err != nil {
return nil, errors.WithMessage(err, "baseURL")
}
if cfg.APIToken == "" {
return nil, errors.WithMessage(errors.WithStack(ErrOptionIsRequired), "APIToken")
}
if cfg.Timeout == 0 {
cfg.Timeout = defaultTimeout
}
hc := req.C().
SetTimeout(cfg.Timeout).
SetCommonBearerAuthToken(cfg.APIToken).
SetCommonHeader("Accept", "application/json").
SetCommonHeader("Content-Type", "application/json")
c := &Client{
baseURL: baseURL.JoinPath(basePath),
apiToken: cfg.APIToken,
httpClient: &httpClient{hc: hc},
debug: cfg.Debug,
}
for _, opt := range opts {
opt(c)
}
return c, nil
}
// Close closes client
func (c *Client) Close() error {
return nil
}
func (c *Client) doRequest(ctx context.Context, body *RPCRequest, result any) error {
if body == nil {
return errors.WithStack(ErrBodyIsRequired)
}
if body.Method == "" {
return errors.WithStack(ErrRPCMethodIsRequired)
}
reqURL := c.baseURL.String() + "?m=" + url.QueryEscape(body.Method)
const skip = 2
fname := functionName(skip)
startTime := time.Now()
var (
err error
reqBodyBytes []byte
respBodyBytes []byte
statusCode int
)
defer func() {
if c.metrics != nil {
c.metrics.RecordRequestDuration(statusCode, body.Method, c.baseURL.Host, fname, time.Since(startTime).Seconds())
}
if c.debug {
c.logDebug(ctx, "Request",
"method", body.Method,
"url", reqURL,
"func", fname,
"requestBody", string(reqBodyBytes),
"responseBody", string(respBodyBytes),
"responseStatus", statusCode,
"duration", time.Since(startTime).String(),
"error", err,
)
}
}()
reqBodyBytes, err = json.Marshal(body)
if err != nil {
return errors.WithMessage(err, "marshal request body")
}
resp, err := c.httpClient.Post(ctx, reqBodyBytes, reqURL)
if err != nil {
return errors.WithMessage(err, "http request failed")
}
statusCode = resp.StatusCode
respBodyBytes = resp.Bytes()
if resp.IsErrorState() {
return errors.Errorf("API error %d: %s", resp.StatusCode, string(resp.Bytes()))
}
// Check for RPC error in 200 OK response
var rpcErr rpcErrorResponse
if err := json.Unmarshal(respBodyBytes, &rpcErr); err != nil {
return errors.WithMessage(err, "unmarshal rpc error check")
}
if rpcErr.Error != nil {
return errors.WithMessagef(rpcErr.Error, "RPC error %d", rpcErr.Error.Code)
}
if result != nil {
if err := json.Unmarshal(respBodyBytes, result); err != nil {
return errors.WithMessage(err, "unmarshal response body")
}
}
return nil
}
func (c *Client) logDebug(ctx context.Context, msg string, args ...any) {
if c.logger != nil && c.debug {
c.logger.Debug(ctx, msg, args...)
}
}
/**
* This file is part of the raoptimus/evateamclient.go library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/evateamclient.go/blob/master/LICENSE.md
* @link https://github.com/raoptimus/evateamclient.go
*/
package evateamclient
import (
"context"
sq "github.com/Masterminds/squirrel"
"github.com/raoptimus/evateamclient.go/models"
)
// Comment field constants for type-safe queries
const (
CommentFieldID = "id"
CommentFieldClassName = "class_name"
CommentFieldText = "text"
CommentFieldLogLevel = "log_level"
CommentFieldParentID = "parent_id" // parent task
CommentFieldAuthorID = "cmf_author_id"
CommentFieldCmfCreatedAt = "cmf_created_at"
CommentFieldCmfOwnerID = "cmf_owner_id"
)
var (
// DefaultCommentFields - standard projection for single comment queries
DefaultCommentFields = []string{
CommentFieldID,
CommentFieldClassName,
CommentFieldText,
CommentFieldAuthorID,
CommentFieldParentID,
CommentFieldCmfCreatedAt,
CommentFieldLogLevel,
}
// DefaultCommentListFields - optimized for LIST queries (lighter payload)
DefaultCommentListFields = []string{
CommentFieldID,
CommentFieldText,
CommentFieldAuthorID,
CommentFieldCmfCreatedAt,
}
)
// Comment retrieves a single comment by ID
// Example:
//
// comment, meta, err := client.Comment(ctx, "Comment:uuid", nil)
func (c *Client) Comment(
ctx context.Context,
commentID string,
fields []string,
) (*models.Comment, *models.Meta, error) {
qb := NewQueryBuilder().
Select(fields...).
From(EntityComment).
Where(sq.Eq{CommentFieldID: commentID}).
Limit(1)
return c.CommentQuery(ctx, qb)
}
// CommentQuery executes query using REAL Squirrel API
// Example:
//
// qb := evateamclient.NewQueryBuilder().
// Select("id", "text", "cmf_author_id").
// From(evateamclient.EntityComment).
// Where(sq.Eq{"id": "Comment:uuid"})
// comment, meta, err := client.CommentQuery(ctx, qb)
func (c *Client) CommentQuery(ctx context.Context, qb *QueryBuilder) (*models.Comment, *models.Meta, error) {
kwargs, err := qb.ToKwargs()
if err != nil {
return nil, nil, err
}
// Apply default fields if none specified
if _, hasFields := kwargs["fields"]; !hasFields {
kwargs["fields"] = DefaultCommentFields
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: "CmfComment.get",
CallID: newCallID(),
Kwargs: kwargs,
}
var resp models.CommentResponse
if err := c.doRequest(ctx, reqBody, &resp); err != nil {
return nil, nil, err
}
return &resp.Result, &resp.Meta, nil
}
// CommentsList retrieves list using REAL Squirrel
// Example:
//
// qb := evateamclient.NewQueryBuilder().
// Select("id", "text", "cmf_author_id", "cmf_created_at").
// From(evateamclient.EntityComment).
// Where(sq.Eq{"task_id": "Task:PROJ-123"}).
// OrderBy("-cmf_created_at").
// Offset(0).Limit(100)
// comments, meta, err := client.CommentsList(ctx, qb)
func (c *Client) CommentsList(
ctx context.Context,
qb *QueryBuilder,
) ([]models.Comment, *models.Meta, error) {
kwargs, err := qb.ToKwargs()
if err != nil {
return nil, nil, err
}
// Apply default fields if none specified
if _, hasFields := kwargs["fields"]; !hasFields {
kwargs["fields"] = DefaultCommentListFields
}
method, err := qb.ToMethod(false)
if err != nil {
return nil, nil, err
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: method,
CallID: newCallID(),
Kwargs: kwargs,
}
var resp models.CommentListResponse
if err := c.doRequest(ctx, reqBody, &resp); err != nil {
return nil, nil, err
}
return resp.Result, &resp.Meta, nil
}
// CommentCount counts using REAL Squirrel
// Example:
//
// qb := evateamclient.NewQueryBuilder().
// From(evateamclient.EntityComment).
// Where(sq.Eq{"task_id": "Task:PROJ-123"})
// count, err := client.CommentCount(ctx, qb)
func (c *Client) CommentCount(
ctx context.Context,
qb *QueryBuilder,
) (int, error) {
kwargs, err := qb.ToKwargs()
if err != nil {
return 0, err
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: "Comment.count",
CallID: newCallID(),
Kwargs: kwargs,
}
var resp struct {
JSONRPC string `json:"jsonrpc"`
Result int `json:"result"`
}
if err := c.doRequest(ctx, reqBody, &resp); err != nil {
return 0, err
}
return resp.Result, nil
}
// TaskComments retrieves ALL comments for task (backward compatible)
// Example:
//
// comments, meta, err := client.TaskComments(ctx, "PROJ-123", nil)
func (c *Client) TaskComments(
ctx context.Context,
taskCode string,
fields []string,
) ([]models.Comment, *models.Meta, error) {
qb := NewQueryBuilder().
Select(fields...).
From(EntityComment).
Where(sq.Eq{CommentFieldParentID: "Task:" + taskCode}).
OrderBy("-" + CommentFieldCmfCreatedAt)
return c.CommentsList(ctx, qb)
}
// TaskCommentsByID retrieves ALL comments for task by task ID
// Example:
//
// comments, meta, err := client.TaskCommentsByID(ctx, "CmfTask:uuid", nil)
func (c *Client) TaskCommentsByID(
ctx context.Context,
taskID string,
fields []string,
) ([]models.Comment, *models.Meta, error) {
qb := NewQueryBuilder().
Select(fields...).
From(EntityComment).
Where(sq.Eq{CommentFieldParentID: taskID}).
OrderBy("-" + CommentFieldCmfCreatedAt)
return c.CommentsList(ctx, qb)
}
// UserComments retrieves ALL comments by specific user
// Example:
//
// comments, meta, err := client.UserComments(ctx, "Person:uuid", nil)
func (c *Client) UserComments(
ctx context.Context,
userID string,
fields []string,
) ([]models.Comment, *models.Meta, error) {
qb := NewQueryBuilder().
Select(fields...).
From(EntityComment).
Where(sq.Eq{CommentFieldAuthorID: userID}).
OrderBy("-" + CommentFieldCmfCreatedAt)
return c.CommentsList(ctx, qb)
}
// Backward compatible methods (using old API)
// Comments retrieves comments with custom filters (backward compatible, deprecated)
// Recommended: use CommentsList with NewQueryBuilder() instead
func (c *Client) Comments(
ctx context.Context,
kwargs map[string]any,
) ([]models.Comment, *models.Meta, error) {
if len(kwargs) == 0 {
kwargs = make(map[string]any)
}
if _, hasFields := kwargs["fields"]; !hasFields {
kwargs["fields"] = DefaultCommentListFields
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: "CmfComment.list",
CallID: newCallID(),
Kwargs: kwargs,
}
var resp models.CommentListResponse
if err := c.doRequest(ctx, reqBody, &resp); err != nil {
return nil, nil, err
}
return resp.Result, &resp.Meta, nil
}
// CRUD Operations
// CommentCreate creates a new comment on a task
// Example:
//
// comment, err := client.CommentCreate(ctx, "Task:PROJ-123", "This is a comment")
func (c *Client) CommentCreate(
ctx context.Context,
taskID string,
text string,
) (*models.Comment, error) {
kwargs := map[string]any{
"task_id": taskID,
"text": text,
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: "Comment.create",
CallID: newCallID(),
Kwargs: kwargs,
}
var resp models.CommentResponse
if err := c.doRequest(ctx, reqBody, &resp); err != nil {
return nil, err
}
return &resp.Result, nil
}
// CommentUpdate updates an existing comment
// Example:
//
// comment, err := client.CommentUpdate(ctx, "Comment:uuid", "Updated text")
func (c *Client) CommentUpdate(
ctx context.Context,
commentID string,
text string,
) (*models.Comment, error) {
kwargs := map[string]any{
"id": commentID,
"text": text,
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: "Comment.update",
CallID: newCallID(),
Kwargs: kwargs,
}
var resp models.CommentResponse
if err := c.doRequest(ctx, reqBody, &resp); err != nil {
return nil, err
}
return &resp.Result, nil
}
// CommentDelete deletes a comment by ID
// Example:
//
// err := client.CommentDelete(ctx, "Comment:uuid")
func (c *Client) CommentDelete(
ctx context.Context,
commentID string,
) error {
kwargs := map[string]any{
"id": commentID,
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: "Comment.delete",
CallID: newCallID(),
Kwargs: kwargs,
}
var resp struct {
JSONRPC string `json:"jsonrpc"`
Result bool `json:"result"`
}
return c.doRequest(ctx, reqBody, &resp)
}
/**
* This file is part of the raoptimus/evateamclient.go library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/evateamclient.go/blob/master/LICENSE.md
* @link https://github.com/raoptimus/evateamclient.go
*/
package evateamclient
import (
"context"
sq "github.com/Masterminds/squirrel"
"github.com/raoptimus/evateamclient.go/models"
)
// Document field constants for type-safe queries
const (
// Core fields
DocumentFieldID = "id"
DocumentFieldClassName = "class_name"
DocumentFieldCode = "code"
DocumentFieldName = "name"
DocumentFieldText = "text"
DocumentFieldProjectID = "project_id"
DocumentFieldParentID = "parent_id"
DocumentFieldCacheStatusType = "cache_status_type"
// System
DocumentFieldCmfCreatedAt = "cmf_created_at"
DocumentFieldCmfModifiedAt = "cmf_modified_at"
DocumentFieldCmfOwnerID = "cmf_owner_id"
DocumentFieldCmfDeleted = "cmf_deleted"
)
var (
// DefaultDocumentFields - standard projection for single document queries
DefaultDocumentFields = []string{
DocumentFieldID,
DocumentFieldClassName,
DocumentFieldCode,
DocumentFieldName,
DocumentFieldText,
DocumentFieldProjectID,
DocumentFieldCacheStatusType,
DocumentFieldCmfCreatedAt,
DocumentFieldCmfModifiedAt,
}
// DefaultDocumentListFields - optimized for LIST queries
DefaultDocumentListFields = []string{
DocumentFieldID,
DocumentFieldCode,
DocumentFieldName,
DocumentFieldProjectID,
DocumentFieldCacheStatusType,
DocumentFieldCmfCreatedAt,
}
)
// Document retrieves a single document by code
// Example:
//
// doc, meta, err := client.Document(ctx, "DOC-123", nil)
func (c *Client) Document(
ctx context.Context,
docCode string,
fields []string,
) (*models.Document, *models.Meta, error) {
qb := NewQueryBuilder().
Select(fields...).
From(EntityDocument).
Where(sq.Eq{DocumentFieldCode: docCode}).
Limit(1)
return c.DocumentQuery(ctx, qb)
}
// DocumentQuery executes query using REAL Squirrel API
// Example:
//
// qb := evateamclient.NewQueryBuilder().
// Select("id", "code", "name", "text").
// From(evateamclient.EntityDocument).
// Where(sq.Eq{"code": "DOC-123"})
// doc, meta, err := client.DocumentQuery(ctx, qb)
func (c *Client) DocumentQuery(ctx context.Context, qb *QueryBuilder) (*models.Document, *models.Meta, error) {
kwargs, err := qb.ToKwargs()
if err != nil {
return nil, nil, err
}
// Apply default fields if none specified
if _, hasFields := kwargs["fields"]; !hasFields {
kwargs["fields"] = DefaultDocumentFields
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: "CmfDocument.get",
CallID: newCallID(),
Kwargs: kwargs,
}
var resp models.DocumentResponse
if err := c.doRequest(ctx, reqBody, &resp); err != nil {
return nil, nil, err
}
return &resp.Result, &resp.Meta, nil
}
// DocumentsList retrieves list using REAL Squirrel
// Example:
//
// qb := evateamclient.NewQueryBuilder().
// Select("id", "code", "name").
// From(evateamclient.EntityDocument).
// Where(sq.Eq{"project_id": "Project:uuid"}).
// OrderBy("-cmf_created_at").
// Offset(0).Limit(100)
// docs, meta, err := client.DocumentsList(ctx, qb)
func (c *Client) DocumentsList(
ctx context.Context,
qb *QueryBuilder,
) ([]models.Document, *models.Meta, error) {
kwargs, err := qb.ToKwargs()
if err != nil {
return nil, nil, err
}
// Apply default fields if none specified
if _, hasFields := kwargs["fields"]; !hasFields {
kwargs["fields"] = DefaultDocumentListFields
}
method, err := qb.ToMethod(false)
if err != nil {
return nil, nil, err
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: method,
CallID: newCallID(),
Kwargs: kwargs,
}
var resp models.DocumentListResponse
if err := c.doRequest(ctx, reqBody, &resp); err != nil {
return nil, nil, err
}
return resp.Result, &resp.Meta, nil
}
// DocumentCount counts using REAL Squirrel
// Example:
//
// qb := evateamclient.NewQueryBuilder().
// From(evateamclient.EntityDocument).
// Where(sq.Eq{"project_id": "Project:uuid"})
// count, err := client.DocumentCount(ctx, qb)
func (c *Client) DocumentCount(
ctx context.Context,
qb *QueryBuilder,
) (int, error) {
kwargs, err := qb.ToKwargs()
if err != nil {
return 0, err
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: "CmfDocument.count",
CallID: newCallID(),
Kwargs: kwargs,
}
var resp struct {
JSONRPC string `json:"jsonrpc"`
Result int `json:"result"`
}
if err := c.doRequest(ctx, reqBody, &resp); err != nil {
return 0, err
}
return resp.Result, nil
}
// ProjectDocuments retrieves ALL documents in project
// Example:
//
// docs, meta, err := client.ProjectDocuments(ctx, "Project:uuid", nil)
func (c *Client) ProjectDocuments(
ctx context.Context,
projectID string,
fields []string,
) ([]models.Document, *models.Meta, error) {
qb := NewQueryBuilder().
Select(fields...).
From(EntityDocument).
Where(sq.Eq{DocumentFieldProjectID: projectID}).
OrderBy("-" + DocumentFieldCmfCreatedAt)
return c.DocumentsList(ctx, qb)
}
// CRUD Operations
// DocumentCreateParams contains parameters for creating a new document
type DocumentCreateParams struct {
Name string `json:"name"`
ProjectID string `json:"project_id"`
Text string `json:"text,omitempty"`
ParentID string `json:"parent_id,omitempty"`
}
// DocumentCreate creates a new document
// Example:
//
// params := evateamclient.DocumentCreateParams{
// Name: "New Document",
// ProjectID: "Project:uuid",
// Text: "Document content",
// }
// doc, err := client.DocumentCreate(ctx, params)
func (c *Client) DocumentCreate(
ctx context.Context,
params DocumentCreateParams,
) (*models.Document, error) {
kwargs := map[string]any{
"name": params.Name,
"project_id": params.ProjectID,
}
if params.Text != "" {
kwargs["text"] = params.Text
}
if params.ParentID != "" {
kwargs["parent_id"] = params.ParentID
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: "CmfDocument.create",
CallID: newCallID(),
Kwargs: kwargs,
}
var resp models.DocumentResponse
if err := c.doRequest(ctx, reqBody, &resp); err != nil {
return nil, err
}
return &resp.Result, nil
}
// DocumentUpdate updates an existing document
// Example:
//
// updates := map[string]any{
// "name": "Updated Document Name",
// "text": "Updated content",
// }
// doc, err := client.DocumentUpdate(ctx, "CmfDocument:uuid", updates)
func (c *Client) DocumentUpdate(
ctx context.Context,
docID string,
updates map[string]any,
) (*models.Document, error) {
kwargs := map[string]any{
"id": docID,
}
for k, v := range updates {
kwargs[k] = v
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: "CmfDocument.update",
CallID: newCallID(),
Kwargs: kwargs,
}
var resp models.DocumentResponse
if err := c.doRequest(ctx, reqBody, &resp); err != nil {
return nil, err
}
return &resp.Result, nil
}
// DocumentDelete deletes a document by ID
// Example:
//
// err := client.DocumentDelete(ctx, "CmfDocument:uuid")
func (c *Client) DocumentDelete(
ctx context.Context,
docID string,
) error {
kwargs := map[string]any{
"id": docID,
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: "CmfDocument.delete",
CallID: newCallID(),
Kwargs: kwargs,
}
var resp struct {
JSONRPC string `json:"jsonrpc"`
Result bool `json:"result"`
}
return c.doRequest(ctx, reqBody, &resp)
}
// DocumentPageTree retrieves the document page tree hierarchy starting from the given node.
// Returns a flat list of documents with parent_id and tree_node_is_branch fields
// for building a tree structure.
// Example:
//
// docs, err := client.DocumentPageTree(ctx, "CmfDocument:uuid")
func (c *Client) DocumentPageTree(
ctx context.Context,
nodeID string,
) ([]models.Document, error) {
kwargs := map[string]any{
"node_id": nodeID,
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: "CmfDocument.macros_page_tree_get",
CallID: newCallID(),
Kwargs: kwargs,
}
var resp models.DocumentListResponse
if err := c.doRequest(ctx, reqBody, &resp); err != nil {
return nil, err
}
return resp.Result, nil
}
// Backward compatible methods (using old API)
// Documents retrieves documents with custom filters (backward compatible, deprecated)
// Recommended: use DocumentsList with NewQueryBuilder() instead
func (c *Client) Documents(
ctx context.Context,
kwargs map[string]any,
) ([]models.Document, *models.Meta, error) {
if len(kwargs) == 0 {
kwargs = make(map[string]any)
}
if _, hasFields := kwargs["fields"]; !hasFields {
kwargs["fields"] = DefaultDocumentListFields
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: "CmfDocument.list",
CallID: newCallID(),
Kwargs: kwargs,
}
var resp models.DocumentListResponse
if err := c.doRequest(ctx, reqBody, &resp); err != nil {
return nil, nil, err
}
return resp.Result, &resp.Meta, nil
}
/**
* This file is part of the raoptimus/evateamclient.go library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/evateamclient.go/blob/master/LICENSE.md
* @link https://github.com/raoptimus/evateamclient.go
*/
package evateamclient
import (
"context"
sq "github.com/Masterminds/squirrel"
"github.com/raoptimus/evateamclient.go/models"
)
// Epic is a CmfTask with logic_type.code = "task.epic"
// Use TaskField* constants for field names
const (
// LogicTypeEpic is the logic_type.code for epics
LogicTypeEpic = "task.epic"
)
var (
// DefaultEpicFields - standard projection for epic queries
DefaultEpicFields = []string{
TaskFieldID,
TaskFieldClassName,
TaskFieldCode,
TaskFieldName,
TaskFieldText,
TaskFieldProjectID,
TaskFieldCacheStatusType,
TaskFieldLogicType,
}
// DefaultEpicListFields - optimized for LIST queries
DefaultEpicListFields = []string{
TaskFieldID,
TaskFieldCode,
TaskFieldName,
TaskFieldProjectID,
TaskFieldCacheStatusType,
}
)
// Epic retrieves a single epic by code
// Note: Epics are CmfTask with logic_type.code = "task.epic"
// Example:
//
// epic, meta, err := client.Epic(ctx, "UDMP-123", nil)
func (c *Client) Epic(
ctx context.Context,
epicCode string,
fields []string,
) (*models.Task, *models.Meta, error) {
if len(fields) == 0 {
fields = DefaultEpicFields
}
kwargs := map[string]any{
"filter": []any{TaskFieldCode, "==", epicCode},
"fields": fields,
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: "CmfTask.get",
CallID: newCallID(),
Kwargs: kwargs,
}
var resp models.TaskResponse
if err := c.doRequest(ctx, reqBody, &resp); err != nil {
return nil, nil, err
}
return &resp.Result, &resp.Meta, nil
}
// EpicByID retrieves a single epic by ID
// Example:
//
// epic, meta, err := client.EpicByID(ctx, "CmfTask:uuid", nil)
func (c *Client) EpicByID(
ctx context.Context,
epicID string,
fields []string,
) (*models.Task, *models.Meta, error) {
if len(fields) == 0 {
fields = DefaultEpicFields
}
kwargs := map[string]any{
"id": epicID,
"fields": fields,
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: "CmfTask.get",
CallID: newCallID(),
Kwargs: kwargs,
}
var resp models.TaskResponse
if err := c.doRequest(ctx, reqBody, &resp); err != nil {
return nil, nil, err
}
return &resp.Result, &resp.Meta, nil
}
// ProjectEpics retrieves ALL epics in project
// Example:
//
// epics, meta, err := client.ProjectEpics(ctx, "CmfProject:uuid", nil)
func (c *Client) ProjectEpics(
ctx context.Context,
projectID string,
fields []string,
) ([]models.TaskBrowse, *models.Meta, error) {
if len(fields) == 0 {
fields = DefaultEpicListFields
}
kwargs := map[string]any{
"filter": [][]any{
{TaskFieldProjectID, "==", projectID},
{"logic_type.code", "==", LogicTypeEpic},
},
"fields": fields,
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: "CmfTask.list",
CallID: newCallID(),
Kwargs: kwargs,
}
var resp models.TaskListResponse
if err := c.doRequest(ctx, reqBody, &resp); err != nil {
return nil, nil, err
}
return resp.Result, &resp.Meta, nil
}
// EpicTasks retrieves ALL tasks in epic by epic ID
// Example:
//
// tasks, meta, err := client.EpicTasks(ctx, "CmfTask:uuid", nil)
func (c *Client) EpicTasks(
ctx context.Context,
epicID string,
fields []string,
) ([]models.TaskBrowse, *models.Meta, error) {
qb := NewQueryBuilder().
Select(fields...).
From(EntityTask).
Where(sq.Eq{TaskFieldEpicID: epicID})
return c.TasksList(ctx, qb)
}
// Epics retrieves epics with custom kwargs
// Example:
//
// epics, meta, err := client.Epics(ctx, map[string]any{
// "filter": [][]any{
// {"project_id", "==", "CmfProject:uuid"},
// {"logic_type.code", "==", "task.epic"},
// },
// })
func (c *Client) Epics(
ctx context.Context,
kwargs map[string]any,
) ([]models.TaskBrowse, *models.Meta, error) {
if len(kwargs) == 0 {
kwargs = make(map[string]any)
}
if _, hasFields := kwargs["fields"]; !hasFields {
kwargs["fields"] = DefaultEpicListFields
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: "CmfTask.list",
CallID: newCallID(),
Kwargs: kwargs,
}
var resp models.TaskListResponse
if err := c.doRequest(ctx, reqBody, &resp); err != nil {
return nil, nil, err
}
return resp.Result, &resp.Meta, nil
}
/**
* This file is part of the raoptimus/evateamclient.go library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/evateamclient.go/blob/master/LICENSE.md
* @link https://github.com/raoptimus/evateamclient.go
*/
package evateamclient
import "github.com/pkg/errors"
var (
ErrOptionIsRequired = errors.New("option is required")
ErrBodyIsRequired = errors.New("body is required")
ErrRPCMethodIsRequired = errors.New("RPCRequest.Method is required")
)
// RPCError represents JSON-RPC error response
type RPCError struct {
Code int `json:"code"`
Message string `json:"message"`
}
func (e *RPCError) Error() string {
return e.Message
}
// rpcErrorResponse is used to check for RPC errors in 200 OK responses
type rpcErrorResponse struct {
Error *RPCError `json:"error,omitempty"`
}
/**
* This file is part of the raoptimus/evateamclient.go library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/evateamclient.go/blob/master/LICENSE.md
* @link https://github.com/raoptimus/evateamclient.go
*/
package evateamclient
import "runtime"
func functionName(skip int) string {
pc, _, _, ok := runtime.Caller(skip)
if !ok {
return "unknown"
}
return runtime.FuncForPC(pc).Name()
}
/**
* This file is part of the raoptimus/evateamclient.go library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/evateamclient.go/blob/master/LICENSE.md
* @link https://github.com/raoptimus/evateamclient.go
*/
package evateamclient
import (
"context"
sq "github.com/Masterminds/squirrel"
"github.com/pkg/errors"
"github.com/raoptimus/evateamclient.go/models"
)
// List field constants for type-safe queries
const (
// Core fields
ListFieldID = "id"
ListFieldClassName = "class_name"
ListFieldCode = "code"
ListFieldName = "name"
ListFieldCacheStatusType = "cache_status_type"
ListFieldCacheMembersCount = "cache_members_count"
ListFieldLimitDays = "limit_days"
// Relations
ListFieldParent = "parent" // nested project object
ListFieldParentID = "parent_id"
ListFieldProjectID = "project_id"
ListFieldWorkflowID = "workflow_id"
// Date fields
ListFieldPlanStartDate = "plan_start_date"
ListFieldPlanEndDate = "plan_end_date"
// Content
ListFieldGoal = "goal"
ListFieldText = "text"
// System
ListFieldSystem = "system"
ListFieldSlOwnerLock = "sl_owner_lock"
ListFieldCmfOwnerID = "cmf_owner_id"
ListFieldCmfCreatedAt = "cmf_created_at"
ListFieldCmfModifiedAt = "cmf_modified_at"
// Code prefixes for list types
ListCodePrefixSprint = "SPR-"
ListCodePrefixRelease = "REL-"
)
var (
// DefaultListFields - standard projection for single list queries
DefaultListFields = []string{
ListFieldID,
ListFieldClassName,
ListFieldCode,
ListFieldName,
ListFieldCacheStatusType,
ListFieldCacheMembersCount,
ListFieldLimitDays,
ListFieldParent,
ListFieldParentID,
ListFieldProjectID,
ListFieldCmfOwnerID,
ListFieldWorkflowID,
ListFieldPlanStartDate,
ListFieldPlanEndDate,
ListFieldGoal,
}
// DefaultListListFields - optimized for LIST queries (lighter payload)
DefaultListListFields = []string{
ListFieldID,
ListFieldClassName,
ListFieldCode,
ListFieldName,
ListFieldCacheStatusType,
ListFieldCacheMembersCount,
ListFieldParent,
ListFieldParentID,
ListFieldProjectID,
ListFieldCmfOwnerID,
ListFieldWorkflowID,
}
)
// List retrieves a single list (sprint/release) by code
// Example:
//
// list, meta, err := client.List(ctx, "SPR-001543", nil)
// list, meta, err := client.List(ctx, "REL-001641", nil)
func (c *Client) List(
ctx context.Context,
listCode string,
fields []string,
) (*models.List, *models.Meta, error) {
qb := NewQueryBuilder().
Select(fields...).
From(EntityList).
Where(sq.Eq{ListFieldCode: listCode}).
Limit(1)
return c.ListQuery(ctx, qb)
}
// ListQuery executes query using QueryBuilder
// Example:
//
// qb := evateamclient.NewQueryBuilder().
// Select("id", "name", "code", "cache_status_type").
// From(evateamclient.EntityList).
// Where(sq.Eq{"code": "SPR-001543"})
// list, meta, err := client.ListQuery(ctx, qb)
func (c *Client) ListQuery(ctx context.Context, qb *QueryBuilder) (*models.List, *models.Meta, error) {
kwargs, err := qb.From(EntityList).ToKwargs()
if err != nil {
return nil, nil, err
}
// Apply default fields if none specified
if _, hasFields := kwargs["fields"]; !hasFields {
kwargs["fields"] = DefaultListFields
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: "CmfList.get",
CallID: newCallID(),
Kwargs: kwargs,
}
var resp models.ListResponse
if err := c.doRequest(ctx, reqBody, &resp); err != nil {
return nil, nil, errors.WithMessage(err, "failed to get list")
}
return &resp.Result, &resp.Meta, nil
}
// ListsList retrieves lists using QueryBuilder
// Example:
//
// qb := evateamclient.NewQueryBuilder().
// Select("id", "code", "name", "cache_status_type").
// From(evateamclient.EntityList).
// Where(sq.Eq{"project_id": "CmfProject:uuid"}).
// Where(sq.Eq{"cache_status_type": evateamclient.StatusTypeOpen}).
// Offset(0).Limit(50)
// lists, meta, err := client.ListsList(ctx, qb)
func (c *Client) ListsList(
ctx context.Context,
qb *QueryBuilder,
) ([]models.List, *models.Meta, error) {
kwargs, err := qb.From(EntityList).ToKwargs()
if err != nil {
return nil, nil, err
}
// Apply default fields if none specified
if _, hasFields := kwargs["fields"]; !hasFields {
kwargs["fields"] = DefaultListListFields
}
method, err := qb.ToMethod(false)
if err != nil {
return nil, nil, err
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: method,
CallID: newCallID(),
Kwargs: kwargs,
}
var resp models.ListListResponse
if err := c.doRequest(ctx, reqBody, &resp); err != nil {
return nil, nil, errors.WithMessage(err, "failed to get lists")
}
return resp.Result, &resp.Meta, nil
}
// ListCount counts lists using QueryBuilder
// Example:
//
// qb := evateamclient.NewQueryBuilder().
// From(evateamclient.EntityList).
// Where(sq.Eq{"project_id": "CmfProject:uuid"})
// count, err := client.ListCount(ctx, qb)
func (c *Client) ListCount(
ctx context.Context,
qb *QueryBuilder,
) (int, error) {
kwargs, err := qb.ToKwargs()
if err != nil {
return 0, err
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: "CmfList.count",
CallID: newCallID(),
Kwargs: kwargs,
}
var resp struct {
JSONRPC string `json:"jsonrpc"`
Result int `json:"result"`
}
if err := c.doRequest(ctx, reqBody, &resp); err != nil {
return 0, errors.WithMessage(err, "failed to get list count")
}
return resp.Result, nil
}
// ProjectLists retrieves ALL lists (sprints + releases) for project
// Example:
//
// lists, meta, err := client.ProjectLists(ctx, "CmfProject:uuid", nil)
func (c *Client) ProjectLists(
ctx context.Context,
projectID string,
fields []string,
) ([]models.List, *models.Meta, error) {
qb := NewQueryBuilder().
Select(fields...).
From(EntityList).
Where(sq.Eq{ListFieldProjectID: projectID})
return c.ListsList(ctx, qb)
}
// OpenProjectLists retrieves all open lists for project
// Example:
//
// lists, meta, err := client.OpenProjectLists(ctx, "CmfProject:uuid", nil)
func (c *Client) OpenProjectLists(
ctx context.Context,
projectID string,
fields []string,
) ([]models.List, *models.Meta, error) {
qb := NewQueryBuilder().
Select(fields...).
From(EntityList).
Where(sq.Eq{ListFieldProjectID: projectID}).
Where(sq.Eq{ListFieldCacheStatusType: models.StatusTypeOpen})
return c.ListsList(ctx, qb)
}
// Lists retrieves lists with custom kwargs
// Example:
//
// lists, meta, err := client.Lists(ctx, map[string]any{
// "filter": []any{"project_id", "==", "CmfProject:uuid"},
// })
func (c *Client) Lists(ctx context.Context, kwargs map[string]any) ([]models.List, *models.Meta, error) {
if len(kwargs) == 0 {
kwargs = make(map[string]any)
}
if _, hasFields := kwargs["fields"]; !hasFields {
kwargs["fields"] = DefaultListListFields
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: "CmfList.list",
CallID: newCallID(),
Kwargs: kwargs,
}
var resp models.ListListResponse
if err := c.doRequest(ctx, reqBody, &resp); err != nil {
return nil, nil, errors.WithMessage(err, "failed to get lists")
}
return resp.Result, &resp.Meta, nil
}
// CRUD Operations
// ListCreateParams contains parameters for creating a new list (sprint/release)
type ListCreateParams struct {
Name string `json:"name"`
ParentID string `json:"parent_id"` // project ID (CmfProject:uuid)
Code string `json:"code,omitempty"`
StartDate string `json:"start_date,omitempty"`
EndDate string `json:"end_date,omitempty"`
Goal string `json:"goal,omitempty"`
}
// ListCreate creates a new list (sprint/release)
// Example:
//
// params := evateamclient.ListCreateParams{
// Name: "Sprint 1",
// ParentID: "CmfProject:uuid",
// }
// list, err := client.ListCreate(ctx, params)
func (c *Client) ListCreate(
ctx context.Context,
params *ListCreateParams,
) (*models.List, error) {
kwargs := map[string]any{
"name": params.Name,
"parent_id": params.ParentID,
}
if params.Code != "" {
kwargs["code"] = params.Code
}
if params.StartDate != "" {
kwargs["start_date"] = params.StartDate
}
if params.EndDate != "" {
kwargs["end_date"] = params.EndDate
}
if params.Goal != "" {
kwargs["goal"] = params.Goal
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: "CmfList.create",
CallID: newCallID(),
Kwargs: kwargs,
}
var resp models.ListResponse
if err := c.doRequest(ctx, reqBody, &resp); err != nil {
return nil, err
}
return &resp.Result, nil
}
// ListUpdate updates an existing list
// Example:
//
// updates := map[string]any{
// "name": "Updated Name",
// "goal": "Complete feature X",
// }
// list, err := client.ListUpdate(ctx, "CmfList:uuid", updates)
func (c *Client) ListUpdate(
ctx context.Context,
listID string,
updates map[string]any,
) (*models.List, error) {
kwargs := map[string]any{
"id": listID,
}
for k, v := range updates {
kwargs[k] = v
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: "CmfList.update",
CallID: newCallID(),
Kwargs: kwargs,
}
var resp models.ListResponse
if err := c.doRequest(ctx, reqBody, &resp); err != nil {
return nil, err
}
return &resp.Result, nil
}
// ListClose closes a list (sprint/release)
// Example:
//
// list, err := client.ListClose(ctx, "CmfList:uuid")
func (c *Client) ListClose(
ctx context.Context,
listID string,
) (*models.List, error) {
return c.ListUpdate(ctx, listID, map[string]any{
"cache_status_type": "CLOSED",
})
}
// ListDelete deletes a list by ID
// Example:
//
// err := client.ListDelete(ctx, "CmfList:uuid")
func (c *Client) ListDelete(
ctx context.Context,
listID string,
) error {
kwargs := map[string]any{
"id": listID,
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: "CmfList.delete",
CallID: newCallID(),
Kwargs: kwargs,
}
var resp struct {
JSONRPC string `json:"jsonrpc"`
Result bool `json:"result"`
}
return c.doRequest(ctx, reqBody, &resp)
}
// =============================================================================
// Sprint-specific methods (code starts with "SPR-")
// =============================================================================
// ProjectSprints retrieves all sprints for project (code like "SPR-%")
// Example:
//
// sprints, meta, err := client.ProjectSprints(ctx, "CmfProject:uuid", nil)
func (c *Client) ProjectSprints(
ctx context.Context,
projectID string,
fields []string,
) ([]models.List, *models.Meta, error) {
qb := NewQueryBuilder().
Select(fields...).
From(EntityList).
Where(sq.Eq{ListFieldProjectID: projectID}).
Where(sq.Like{ListFieldCode: ListCodePrefixSprint + "%"})
return c.ListsList(ctx, qb)
}
// OpenProjectSprints retrieves open sprints for project
// Example:
//
// sprints, meta, err := client.OpenProjectSprints(ctx, "CmfProject:uuid", nil)
func (c *Client) OpenProjectSprints(
ctx context.Context,
projectID string,
fields []string,
) ([]models.List, *models.Meta, error) {
qb := NewQueryBuilder().
Select(fields...).
From(EntityList).
Where(sq.Eq{ListFieldProjectID: projectID}).
Where(sq.Like{ListFieldCode: ListCodePrefixSprint + "%"}).
Where(sq.Eq{ListFieldCacheStatusType: models.StatusTypeOpen})
return c.ListsList(ctx, qb)
}
// Sprints retrieves sprints with custom kwargs
// Example:
//
// sprints, meta, err := client.Sprints(ctx, map[string]any{
// "filter": [][]any{
// {"project_id", "==", "CmfProject:uuid"},
// {"code", "LIKE", "SPR-%"},
// },
// })
func (c *Client) Sprints(ctx context.Context, kwargs map[string]any) ([]models.List, *models.Meta, error) {
return c.Lists(ctx, kwargs)
}
// =============================================================================
// Release-specific methods (code starts with "REL-")
// =============================================================================
// ProjectReleases retrieves all releases for project (code like "REL-%")
// Example:
//
// releases, meta, err := client.ProjectReleases(ctx, "CmfProject:uuid", nil)
func (c *Client) ProjectReleases(
ctx context.Context,
projectID string,
fields []string,
) ([]models.List, *models.Meta, error) {
qb := NewQueryBuilder().
Select(fields...).
From(EntityList).
Where(sq.Eq{ListFieldProjectID: projectID}).
Where(sq.Like{ListFieldCode: ListCodePrefixRelease + "%"})
return c.ListsList(ctx, qb)
}
// OpenProjectReleases retrieves open releases for project
// Example:
//
// releases, meta, err := client.OpenProjectReleases(ctx, "CmfProject:uuid", nil)
func (c *Client) OpenProjectReleases(
ctx context.Context,
projectID string,
fields []string,
) ([]models.List, *models.Meta, error) {
qb := NewQueryBuilder().
Select(fields...).
From(EntityList).
Where(sq.Eq{ListFieldProjectID: projectID}).
Where(sq.Like{ListFieldCode: ListCodePrefixRelease + "%"}).
Where(sq.Eq{ListFieldCacheStatusType: models.StatusTypeOpen})
return c.ListsList(ctx, qb)
}
// Releases retrieves releases with custom kwargs
// Example:
//
// releases, meta, err := client.Releases(ctx, map[string]any{
// "filter": [][]any{
// {"project_id", "==", "CmfProject:uuid"},
// {"code", "LIKE", "REL-%"},
// },
// })
func (c *Client) Releases(ctx context.Context, kwargs map[string]any) ([]models.List, *models.Meta, error) {
return c.Lists(ctx, kwargs)
}
/**
* This file is part of the raoptimus/evateamclient.go library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/evateamclient.go/blob/master/LICENSE.md
* @link https://github.com/raoptimus/evateamclient.go
*/
package evateamclient
import (
"context"
sq "github.com/Masterminds/squirrel"
"github.com/pkg/errors"
"github.com/raoptimus/evateamclient.go/models"
)
// Logic type field constants for type-safe queries
const (
LogicTypeFieldID = "id"
LogicTypeFieldClassName = "class_name"
LogicTypeFieldName = "name"
LogicTypeFieldCode = "code"
LogicTypeFieldCmfModelName = "cmf_model_name"
LogicTypeFieldParentID = "parent_id"
LogicTypeFieldProjectID = "project_id"
)
// Well-known logic type codes for tasks. Actual codes are install-specific
// and may differ between deployments. The values below match the common defaults.
// Note: LogicTypeEpic ("task.epic") in epic.go is the legacy short form
// kept for backward compatibility with older deployments.
const (
LogicTypeCodeEpic = "task.epic:default"
LogicTypeCodeStory = "task.userstory:story"
LogicTypeCodeTask = "task.agile:task"
LogicTypeCodeBug = "task.bug:default"
)
// DefaultLogicTypeFields - standard projection for LogicType queries
var DefaultLogicTypeFields = []string{
LogicTypeFieldID,
LogicTypeFieldClassName,
LogicTypeFieldName,
LogicTypeFieldCode,
LogicTypeFieldCmfModelName,
}
// LogicTypeList retrieves logic types using a QueryBuilder.
// Example:
//
// qb := evateamclient.NewQueryBuilder().
// Select("id", "code", "name").
// From(evateamclient.EntityLogicType).
// Where(sq.Eq{"code": "task.epic"})
// items, meta, err := client.LogicTypeList(ctx, qb)
func (c *Client) LogicTypeList(
ctx context.Context,
qb *QueryBuilder,
) ([]models.LogicType, *models.Meta, error) {
kwargs, err := qb.ToKwargs()
if err != nil {
return nil, nil, err
}
if _, hasFields := kwargs["fields"]; !hasFields {
kwargs["fields"] = DefaultLogicTypeFields
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: "CmfLogicType.list",
CallID: newCallID(),
Kwargs: kwargs,
}
var resp models.LogicTypeListResponse
if err := c.doRequest(ctx, reqBody, &resp); err != nil {
return nil, nil, errors.WithMessage(err, "failed to list logic types")
}
return resp.Result, &resp.Meta, nil
}
// LogicTypeByCode retrieves a single LogicType by its code
// (e.g. "task.epic", "task.story"). Returns an error if no
// logic type with that code is found on the server.
// Example:
//
// lt, err := client.LogicTypeByCode(ctx, evateamclient.LogicTypeEpic)
// // lt.ID -> "CmfLogicType:..."
func (c *Client) LogicTypeByCode(
ctx context.Context,
code string,
) (*models.LogicType, error) {
qb := NewQueryBuilder().
Select(DefaultLogicTypeFields...).
From(EntityLogicType).
Where(sq.Eq{LogicTypeFieldCode: code}).
Limit(1)
items, _, err := c.LogicTypeList(ctx, qb)
if err != nil {
return nil, err
}
if len(items) == 0 {
return nil, errors.Errorf("logic type with code %q not found", code)
}
return &items[0], nil
}
/**
* This file is part of the raoptimus/evateamclient.go library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/evateamclient.go/blob/master/LICENSE.md
* @link https://github.com/raoptimus/evateamclient.go
*/
package logrus
import (
"context"
"github.com/sirupsen/logrus"
)
type Logrus struct {
logger *logrus.Logger
}
func New(logger *logrus.Logger) *Logrus {
return &Logrus{logger: logger}
}
func (a *Logrus) Debug(ctx context.Context, msg string, args ...any) {
fields := a.argsToFields(args)
a.logger.WithContext(ctx).WithFields(fields).Debug(msg)
}
func (a *Logrus) Info(ctx context.Context, msg string, args ...any) {
fields := a.argsToFields(args)
a.logger.WithContext(ctx).WithFields(fields).Info(msg)
}
func (a *Logrus) Warn(ctx context.Context, msg string, args ...any) {
fields := a.argsToFields(args)
a.logger.WithContext(ctx).WithFields(fields).Warn(msg)
}
func (a *Logrus) Error(ctx context.Context, msg string, args ...any) {
fields := a.argsToFields(args)
a.logger.WithContext(ctx).WithFields(fields).Error(msg)
}
func (a *Logrus) argsToFields(args []any) logrus.Fields {
fields := make(logrus.Fields, len(args))
for i := 0; i < len(args)-1; i += 2 {
if key, ok := args[i].(string); ok {
fields[key] = args[i+1]
}
}
return fields
}
/**
* This file is part of the raoptimus/evateamclient.go library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/evateamclient.go/blob/master/LICENSE.md
* @link https://github.com/raoptimus/evateamclient.go
*/
package evateamclient
import (
"errors"
"strconv"
"github.com/prometheus/client_golang/prometheus"
)
type Metrics interface {
RecordRequestDuration(status int, method, host, function string, duration float64)
}
// PrometheusMetrics holds Prometheus metrics for the eva.team client
type PrometheusMetrics struct {
RequestDuration prometheus.HistogramVec
}
func NewPrometheusMetrics() *PrometheusMetrics {
return &PrometheusMetrics{
RequestDuration: *prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Name: "eva_client_request_duration_seconds",
Help: "Duration of eva.team API requests in seconds",
Buckets: []float64{0.01, 0.05, 0.1, 0.5, 1, 2, 5, 10},
},
[]string{"status", "method", "host", "function"},
),
}
}
// Register registers metrics with Prometheus registry
func (m *PrometheusMetrics) Register(registerer prometheus.Registerer) error {
if err := registerer.Register(&m.RequestDuration); err != nil {
// If already registered, that's fine
var alreadyRegisteredError prometheus.AlreadyRegisteredError
if errors.As(err, &alreadyRegisteredError) {
return nil
}
return err
}
return nil
}
// Unregister unregisters metrics from Prometheus registry
func (m *PrometheusMetrics) Unregister(registerer prometheus.Registerer) bool {
return registerer.Unregister(&m.RequestDuration)
}
// RecordRequestDuration writes duration request with labels
func (m *PrometheusMetrics) RecordRequestDuration(status int, method, host, function string, duration float64) {
m.RequestDuration.WithLabelValues(strconv.Itoa(status), method, host, function).Observe(duration)
}
// Code generated by mockery v2.53.5. DO NOT EDIT.
package mockevateamclient
import (
context "context"
mock "github.com/stretchr/testify/mock"
req "github.com/imroc/req/v3"
)
// HTTPClient is an autogenerated mock type for the HTTPClient type
type HTTPClient struct {
mock.Mock
}
type HTTPClient_Expecter struct {
mock *mock.Mock
}
func (_m *HTTPClient) EXPECT() *HTTPClient_Expecter {
return &HTTPClient_Expecter{mock: &_m.Mock}
}
// Post provides a mock function with given fields: ctx, body, url
func (_m *HTTPClient) Post(ctx context.Context, body []byte, url string) (*req.Response, error) {
ret := _m.Called(ctx, body, url)
if len(ret) == 0 {
panic("no return value specified for Post")
}
var r0 *req.Response
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, []byte, string) (*req.Response, error)); ok {
return rf(ctx, body, url)
}
if rf, ok := ret.Get(0).(func(context.Context, []byte, string) *req.Response); ok {
r0 = rf(ctx, body, url)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*req.Response)
}
}
if rf, ok := ret.Get(1).(func(context.Context, []byte, string) error); ok {
r1 = rf(ctx, body, url)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// HTTPClient_Post_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Post'
type HTTPClient_Post_Call struct {
*mock.Call
}
// Post is a helper method to define mock.On call
// - ctx context.Context
// - body []byte
// - url string
func (_e *HTTPClient_Expecter) Post(ctx interface{}, body interface{}, url interface{}) *HTTPClient_Post_Call {
return &HTTPClient_Post_Call{Call: _e.mock.On("Post", ctx, body, url)}
}
func (_c *HTTPClient_Post_Call) Run(run func(ctx context.Context, body []byte, url string)) *HTTPClient_Post_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].([]byte), args[2].(string))
})
return _c
}
func (_c *HTTPClient_Post_Call) Return(_a0 *req.Response, _a1 error) *HTTPClient_Post_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *HTTPClient_Post_Call) RunAndReturn(run func(context.Context, []byte, string) (*req.Response, error)) *HTTPClient_Post_Call {
_c.Call.Return(run)
return _c
}
// NewHTTPClient creates a new instance of HTTPClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewHTTPClient(t interface {
mock.TestingT
Cleanup(func())
}) *HTTPClient {
mock := &HTTPClient{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
// Code generated by mockery v2.53.5. DO NOT EDIT.
package mockevateamclient
import (
context "context"
mock "github.com/stretchr/testify/mock"
)
// Logger is an autogenerated mock type for the Logger type
type Logger struct {
mock.Mock
}
type Logger_Expecter struct {
mock *mock.Mock
}
func (_m *Logger) EXPECT() *Logger_Expecter {
return &Logger_Expecter{mock: &_m.Mock}
}
// Debug provides a mock function with given fields: ctx, msg, args
func (_m *Logger) Debug(ctx context.Context, msg string, args ...any) {
var _ca []interface{}
_ca = append(_ca, ctx, msg)
_ca = append(_ca, args...)
_m.Called(_ca...)
}
// Logger_Debug_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Debug'
type Logger_Debug_Call struct {
*mock.Call
}
// Debug is a helper method to define mock.On call
// - ctx context.Context
// - msg string
// - args ...any
func (_e *Logger_Expecter) Debug(ctx interface{}, msg interface{}, args ...interface{}) *Logger_Debug_Call {
return &Logger_Debug_Call{Call: _e.mock.On("Debug",
append([]interface{}{ctx, msg}, args...)...)}
}
func (_c *Logger_Debug_Call) Run(run func(ctx context.Context, msg string, args ...any)) *Logger_Debug_Call {
_c.Call.Run(func(args mock.Arguments) {
variadicArgs := make([]any, len(args)-2)
for i, a := range args[2:] {
if a != nil {
variadicArgs[i] = a.(any)
}
}
run(args[0].(context.Context), args[1].(string), variadicArgs...)
})
return _c
}
func (_c *Logger_Debug_Call) Return() *Logger_Debug_Call {
_c.Call.Return()
return _c
}
func (_c *Logger_Debug_Call) RunAndReturn(run func(context.Context, string, ...any)) *Logger_Debug_Call {
_c.Run(run)
return _c
}
// Error provides a mock function with given fields: ctx, msg, args
func (_m *Logger) Error(ctx context.Context, msg string, args ...any) {
var _ca []interface{}
_ca = append(_ca, ctx, msg)
_ca = append(_ca, args...)
_m.Called(_ca...)
}
// Logger_Error_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Error'
type Logger_Error_Call struct {
*mock.Call
}
// Error is a helper method to define mock.On call
// - ctx context.Context
// - msg string
// - args ...any
func (_e *Logger_Expecter) Error(ctx interface{}, msg interface{}, args ...interface{}) *Logger_Error_Call {
return &Logger_Error_Call{Call: _e.mock.On("Error",
append([]interface{}{ctx, msg}, args...)...)}
}
func (_c *Logger_Error_Call) Run(run func(ctx context.Context, msg string, args ...any)) *Logger_Error_Call {
_c.Call.Run(func(args mock.Arguments) {
variadicArgs := make([]any, len(args)-2)
for i, a := range args[2:] {
if a != nil {
variadicArgs[i] = a.(any)
}
}
run(args[0].(context.Context), args[1].(string), variadicArgs...)
})
return _c
}
func (_c *Logger_Error_Call) Return() *Logger_Error_Call {
_c.Call.Return()
return _c
}
func (_c *Logger_Error_Call) RunAndReturn(run func(context.Context, string, ...any)) *Logger_Error_Call {
_c.Run(run)
return _c
}
// Info provides a mock function with given fields: ctx, msg, args
func (_m *Logger) Info(ctx context.Context, msg string, args ...any) {
var _ca []interface{}
_ca = append(_ca, ctx, msg)
_ca = append(_ca, args...)
_m.Called(_ca...)
}
// Logger_Info_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Info'
type Logger_Info_Call struct {
*mock.Call
}
// Info is a helper method to define mock.On call
// - ctx context.Context
// - msg string
// - args ...any
func (_e *Logger_Expecter) Info(ctx interface{}, msg interface{}, args ...interface{}) *Logger_Info_Call {
return &Logger_Info_Call{Call: _e.mock.On("Info",
append([]interface{}{ctx, msg}, args...)...)}
}
func (_c *Logger_Info_Call) Run(run func(ctx context.Context, msg string, args ...any)) *Logger_Info_Call {
_c.Call.Run(func(args mock.Arguments) {
variadicArgs := make([]any, len(args)-2)
for i, a := range args[2:] {
if a != nil {
variadicArgs[i] = a.(any)
}
}
run(args[0].(context.Context), args[1].(string), variadicArgs...)
})
return _c
}
func (_c *Logger_Info_Call) Return() *Logger_Info_Call {
_c.Call.Return()
return _c
}
func (_c *Logger_Info_Call) RunAndReturn(run func(context.Context, string, ...any)) *Logger_Info_Call {
_c.Run(run)
return _c
}
// Warn provides a mock function with given fields: ctx, msg, args
func (_m *Logger) Warn(ctx context.Context, msg string, args ...any) {
var _ca []interface{}
_ca = append(_ca, ctx, msg)
_ca = append(_ca, args...)
_m.Called(_ca...)
}
// Logger_Warn_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Warn'
type Logger_Warn_Call struct {
*mock.Call
}
// Warn is a helper method to define mock.On call
// - ctx context.Context
// - msg string
// - args ...any
func (_e *Logger_Expecter) Warn(ctx interface{}, msg interface{}, args ...interface{}) *Logger_Warn_Call {
return &Logger_Warn_Call{Call: _e.mock.On("Warn",
append([]interface{}{ctx, msg}, args...)...)}
}
func (_c *Logger_Warn_Call) Run(run func(ctx context.Context, msg string, args ...any)) *Logger_Warn_Call {
_c.Call.Run(func(args mock.Arguments) {
variadicArgs := make([]any, len(args)-2)
for i, a := range args[2:] {
if a != nil {
variadicArgs[i] = a.(any)
}
}
run(args[0].(context.Context), args[1].(string), variadicArgs...)
})
return _c
}
func (_c *Logger_Warn_Call) Return() *Logger_Warn_Call {
_c.Call.Return()
return _c
}
func (_c *Logger_Warn_Call) RunAndReturn(run func(context.Context, string, ...any)) *Logger_Warn_Call {
_c.Run(run)
return _c
}
// NewLogger creates a new instance of Logger. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewLogger(t interface {
mock.TestingT
Cleanup(func())
}) *Logger {
mock := &Logger{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
// Code generated by mockery v2.53.5. DO NOT EDIT.
package mockevateamclient
import mock "github.com/stretchr/testify/mock"
// Metrics is an autogenerated mock type for the Metrics type
type Metrics struct {
mock.Mock
}
type Metrics_Expecter struct {
mock *mock.Mock
}
func (_m *Metrics) EXPECT() *Metrics_Expecter {
return &Metrics_Expecter{mock: &_m.Mock}
}
// RecordRequestDuration provides a mock function with given fields: status, method, host, function, duration
func (_m *Metrics) RecordRequestDuration(status int, method string, host string, function string, duration float64) {
_m.Called(status, method, host, function, duration)
}
// Metrics_RecordRequestDuration_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RecordRequestDuration'
type Metrics_RecordRequestDuration_Call struct {
*mock.Call
}
// RecordRequestDuration is a helper method to define mock.On call
// - status int
// - method string
// - host string
// - function string
// - duration float64
func (_e *Metrics_Expecter) RecordRequestDuration(status interface{}, method interface{}, host interface{}, function interface{}, duration interface{}) *Metrics_RecordRequestDuration_Call {
return &Metrics_RecordRequestDuration_Call{Call: _e.mock.On("RecordRequestDuration", status, method, host, function, duration)}
}
func (_c *Metrics_RecordRequestDuration_Call) Run(run func(status int, method string, host string, function string, duration float64)) *Metrics_RecordRequestDuration_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(int), args[1].(string), args[2].(string), args[3].(string), args[4].(float64))
})
return _c
}
func (_c *Metrics_RecordRequestDuration_Call) Return() *Metrics_RecordRequestDuration_Call {
_c.Call.Return()
return _c
}
func (_c *Metrics_RecordRequestDuration_Call) RunAndReturn(run func(int, string, string, string, float64)) *Metrics_RecordRequestDuration_Call {
_c.Run(run)
return _c
}
// NewMetrics creates a new instance of Metrics. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMetrics(t interface {
mock.TestingT
Cleanup(func())
}) *Metrics {
mock := &Metrics{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
// Code generated by mockery v2.53.5. DO NOT EDIT.
package mockevateamclient
import (
evateamclient "github.com/raoptimus/evateamclient.go"
mock "github.com/stretchr/testify/mock"
)
// Option is an autogenerated mock type for the Option type
type Option struct {
mock.Mock
}
type Option_Expecter struct {
mock *mock.Mock
}
func (_m *Option) EXPECT() *Option_Expecter {
return &Option_Expecter{mock: &_m.Mock}
}
// Execute provides a mock function with given fields: _a0
func (_m *Option) Execute(_a0 *evateamclient.Client) {
_m.Called(_a0)
}
// Option_Execute_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Execute'
type Option_Execute_Call struct {
*mock.Call
}
// Execute is a helper method to define mock.On call
// - _a0 *evateamclient.Client
func (_e *Option_Expecter) Execute(_a0 interface{}) *Option_Execute_Call {
return &Option_Execute_Call{Call: _e.mock.On("Execute", _a0)}
}
func (_c *Option_Execute_Call) Run(run func(_a0 *evateamclient.Client)) *Option_Execute_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(*evateamclient.Client))
})
return _c
}
func (_c *Option_Execute_Call) Return() *Option_Execute_Call {
_c.Call.Return()
return _c
}
func (_c *Option_Execute_Call) RunAndReturn(run func(*evateamclient.Client)) *Option_Execute_Call {
_c.Run(run)
return _c
}
// NewOption creates a new instance of Option. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewOption(t interface {
mock.TestingT
Cleanup(func())
}) *Option {
mock := &Option{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
/**
* This file is part of the raoptimus/evateamclient.go library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/evateamclient.go/blob/master/LICENSE.md
* @link https://github.com/raoptimus/evateamclient.go
*/
package models
import (
"strings"
"time"
)
const (
ListSprintPrefix = "SPR-"
ListReleasePrefix = "REL-"
)
// ListParent represents nested parent project info in list response.
type ListParent struct {
ID string `json:"id"`
ClassName string `json:"class_name"`
ParentID *string `json:"parent_id"`
ProjectID string `json:"project_id"`
CmfOwnerID string `json:"cmf_owner_id"`
Name string `json:"name"`
Code string `json:"code"`
WorkflowID string `json:"workflow_id"`
}
// List represents CmfList object (sprint or release) from CmfList.get/list.
// Use IsSprint() or IsRelease() to determine the type by code prefix.
type List struct {
ID string `json:"id"`
ClassName string `json:"class_name"`
Code string `json:"code"`
Name string `json:"name"`
CacheStatusType string `json:"cache_status_type,omitempty"`
CacheMembersCount int `json:"cache_members_count,omitempty"`
LimitDays string `json:"limit_days,omitempty"`
Parent *ListParent `json:"parent,omitempty"`
ParentID string `json:"parent_id,omitempty"`
ProjectID string `json:"project_id,omitempty"`
CmfOwnerID string `json:"cmf_owner_id,omitempty"`
WorkflowID string `json:"workflow_id,omitempty"`
PlanStartDate time.Time `json:"plan_start_date,omitempty"`
PlanEndDate time.Time `json:"plan_end_date,omitempty"`
Goal string `json:"goal,omitempty"`
Text string `json:"text,omitempty"`
System bool `json:"system,omitempty"`
SlOwnerLock bool `json:"sl_owner_lock,omitempty"`
}
// IsSprint returns true if this list is a sprint (code starts with "SPR-").
func (l *List) IsSprint() bool {
return strings.HasPrefix(l.Code, ListSprintPrefix)
}
// IsRelease returns true if this list is a release (code starts with "REL-").
func (l *List) IsRelease() bool {
return strings.HasPrefix(l.Code, ListReleasePrefix)
}
// ListResponse for CmfList.get (single list).
type ListResponse struct {
JSONRPC string `json:"jsonrpc,omitempty"`
Result List `json:"result,omitempty"`
Meta Meta `json:"meta,omitempty"`
}
// ListListResponse for CmfList.list.
type ListListResponse struct {
JSONRPC string `json:"jsonrpc,omitempty"`
Result []List `json:"result,omitempty"`
Meta Meta `json:"meta,omitempty"`
}
/**
* This file is part of the raoptimus/evateamclient.go library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/evateamclient.go/blob/master/LICENSE.md
* @link https://github.com/raoptimus/evateamclient.go
*/
package models
import (
"strings"
"time"
)
type TaskBrowse struct {
ID string `json:"id"`
ClassName string `json:"class_name"`
AgileStoryPoints string `json:"agile_story_points"` // Story Points
CacheStatusType string `json:"cache_status_type"`
Code string `json:"code"`
Deadline time.Time `json:"deadline,omitempty"`
EpicID string `json:"epic_id,omitempty"`
Name string `json:"name"`
Priority int `json:"priority,omitempty"`
ProjectID string `json:"project_id,omitempty"`
ResponsibleID string `json:"responsible_id,omitempty"`
CacheChildTasksCount int `json:"cache_child_tasks_count"`
ParentID string `json:"parent_id,omitempty"` // ParentID - project id
ParentTaskID string `json:"parent_task_id,omitempty"` // ID of parent task (for sub-tasks)
CmfOwnerID string `json:"cmf_owner_id"`
WorkflowID string `json:"workflow_id"`
StatusID string `json:"status_id,omitempty"`
Status *Status `json:"status,omitempty"`
StatusClosedAt time.Time `json:"status_closed_at,omitempty"`
}
// Task represents COMPLETE task object from Task.get/list.
type Task struct {
TaskBrowse
Text string `json:"text,omitempty"`
Mark string `json:"mark,omitempty"`
AlarmDate *string `json:"alarm_date,omitempty"`
// Nested relations (embedded objects)
Responsible *Person `json:"responsible,omitempty"`
LogicType *LogicType `json:"logic_type,omitempty"`
Epic *Task `json:"epic,omitempty"` // null or nested task
WaitingFor *Person `json:"waiting_for,omitempty"`
ParentTask string `json:"parent_task,omitempty"`
// Arrays
Components []*Component `json:"components,omitempty"`
Lists []*List `json:"lists,omitempty"` // Sprints
FixVersions []*List `json:"fix_versions,omitempty"`
Tags []*Tag `json:"tags,omitempty"`
Executors []*Person `json:"executors,omitempty"`
Spectators []*Person `json:"spectators,omitempty"`
// System Fields
CmfLockedAt time.Time `json:"cmf_locked_at,omitempty"`
CmfCreatedAt time.Time `json:"cmf_created_at,omitempty"`
CmfModifiedAt time.Time `json:"cmf_modified_at,omitempty"`
CmfViewedAt time.Time `json:"cmf_viewed_at,omitempty"`
CmfDeleted bool `json:"cmf_deleted,omitempty"`
CmfVersion string `json:"cmf_version,omitempty"`
// Import Data
// ImportRawJSON any `json:"import_raw_json,omitempty"`
// Additional Fields
ExtID string `json:"ext_id,omitempty"`
Approved bool `json:"approved,omitempty"`
IsPublic bool `json:"is_public,omitempty"`
NoControl bool `json:"no_control,omitempty"`
IsFlagged bool `json:"is_flagged,omitempty"`
// Dates
PlanStartDate time.Time `json:"plan_start_date,omitempty"`
PlanEndDate time.Time `json:"plan_end_date,omitempty"`
PeriodInterval string `json:"period_interval,omitempty"`
PeriodNextDate string `json:"period_next_date,omitempty"`
// Status Tracking
StatusModifiedAt time.Time `json:"status_modified_at,omitempty"`
StatusInProgressStart time.Time `json:"status_in_progress_start,omitempty"`
StatusInProgressEnd time.Time `json:"status_in_progress_end,omitempty"`
StatusReviewAt time.Time `json:"status_review_at,omitempty"`
// Additional Flags
ArchiveDate time.Time `json:"archiveddate,omitempty"`
ResultText string `json:"result_text,omitempty"`
// System fields
LogicTypeID string `json:"logic_type_id,omitempty"`
}
func (t *TaskBrowse) IsClosedBetween(since, till time.Time) bool {
if !strings.EqualFold(t.CacheStatusType, StatusTypeClosed) || t.StatusClosedAt.IsZero() {
return false
}
return t.StatusClosedAt.After(since) && t.StatusClosedAt.Before(till)
}
// TaskResponse for Task.get (single task).
type TaskResponse struct {
JSONRPC string `json:"jsonrpc"`
Result Task `json:"result"`
Meta Meta `json:"meta,omitempty"`
}
// TaskListResponse for Task.list.
type TaskListResponse struct {
JSONRPC string `json:"jsonrpc"`
Result []TaskBrowse `json:"result"`
Meta Meta `json:"meta,omitempty"`
}
/**
* This file is part of the raoptimus/evateamclient.go library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/evateamclient.go/blob/master/LICENSE.md
* @link https://github.com/raoptimus/evateamclient.go
*/
package evateamclient
import (
"context"
sq "github.com/Masterminds/squirrel"
"github.com/pkg/errors"
"github.com/raoptimus/evateamclient.go/models"
)
// Person field constants for type-safe queries
const (
// Core identity fields (always readable)
PersonFieldID = "id"
PersonFieldName = "name"
PersonFieldCode = "code"
PersonFieldLogin = "login"
// Contact info (readonly)
PersonFieldEmail = "email"
PersonFieldEmail2 = "email_2"
PersonFieldPhone = "phone"
PersonFieldPhoneInternal = "phone_internal"
PersonFieldPhoneMobile = "phone_mobile"
PersonFieldPhone2 = "phone_2"
PersonFieldPhoneAssistant = "phone_assistant"
PersonFieldSkype = "skype"
PersonFieldTelegram = "telegram"
PersonFieldWhatsApp = "whatsapp"
PersonFieldSlack = "slack"
PersonFieldZoom = "zoom"
PersonFieldLinkedin = "linkedin"
PersonFieldFacebook = "facebook"
PersonFieldInstagram = "instagram"
PersonFieldVK = "vk"
PersonFieldOK = "ok"
// Personal info
PersonFieldFirstName = "first_name"
PersonFieldLastName = "last_name"
PersonFieldSecondName = "second_name"
PersonFieldWorkPosition = "work_position"
PersonFieldBirthday = "birthday"
// Status
PersonFieldOnVacation = "on_vacation"
PersonFieldDoesNotWork = "does_not_work"
PersonFieldAvatarFilename = "avatar_filename"
// Client info
PersonFieldClientJobName = "client_job_name"
PersonFieldClientDepartment = "client_department"
PersonFieldClientDivision = "client_division"
PersonFieldClientOffice = "client_office"
// System
PersonFieldProjectID = "project_id"
PersonFieldParentID = "parent_id"
PersonFieldCmfOwnerID = "cmf_owner_id"
PersonFieldCreatedAt = "cmf_created_at"
PersonFieldModifiedAt = "cmf_modified_at"
PersonFieldDeletedLogin = "deleted_login"
PersonFieldOldLogin = "old_login"
PersonFieldExtID = "ext_id"
PersonFieldOrderNo = "orderno"
// DENY fields (hidden)
// activity_id, api_token_hash, auth_*, password_*, tokens, etc.
)
var (
// DefaultPersonFields - standard projection for single person queries
DefaultPersonFields = []string{
PersonFieldID,
PersonFieldName,
PersonFieldCode,
PersonFieldLogin,
PersonFieldEmail,
PersonFieldOnVacation,
PersonFieldDoesNotWork,
PersonFieldCreatedAt,
}
// DefaultPersonListFields - optimized for LIST queries (lighter payload)
DefaultPersonListFields = []string{
PersonFieldID,
PersonFieldName,
PersonFieldCode,
PersonFieldLogin,
PersonFieldEmail,
PersonFieldOnVacation,
PersonFieldDoesNotWork,
}
)
// Person retrieves a single person by ID (backward compatible)
// Example:
//
// person, meta, err := client.Person(ctx, "Person:uuid-here", nil)
func (c *Client) Person(
ctx context.Context,
userID string,
fields []string,
) (*models.Person, *models.Meta, error) {
qb := NewQueryBuilder().
Select(fields...).
From(EntityPerson).
Where(sq.Eq{PersonFieldID: userID}).
Limit(1)
return c.PersonQuery(ctx, qb)
}
// PersonQuery executes query using REAL Squirrel API
// Example:
//
// qb := evateamclient.NewQueryBuilder().
// Select("id", "name", "email").
// From(evateamclient.EntityPerson).
// Where(sq.Eq{"login": "john.doe"})
// person, meta, err := client.PersonQuery(ctx, qb)
func (c *Client) PersonQuery(ctx context.Context, qb *QueryBuilder) (*models.Person, *models.Meta, error) {
kwargs, err := qb.ToKwargs()
if err != nil {
return nil, nil, err
}
// Apply default fields if none specified
if _, hasFields := kwargs["fields"]; !hasFields {
kwargs["fields"] = DefaultPersonFields
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: "CmfPerson.get",
CallID: newCallID(),
Kwargs: kwargs,
}
var resp models.PersonResponse
if err := c.doRequest(ctx, reqBody, &resp); err != nil {
return nil, nil, errors.WithMessage(err, "failed to get person")
}
return &resp.Result, &resp.Meta, nil
}
// PersonsList retrieves list using REAL Squirrel
// Example:
//
// qb := evateamclient.NewQueryBuilder().
// Select("id", "name", "email").
// From(evateamclient.EntityPerson).
// Where(sq.Eq{"on_vacation": false}).
// Where(sq.Eq{"does_not_work": false}).
// OrderBy("name").
// Offset(0).Limit(100)
// persons, meta, err := client.PersonsList(ctx, qb)
func (c *Client) PersonsList(
ctx context.Context,
qb *QueryBuilder,
) ([]models.Person, *models.Meta, error) {
kwargs, err := qb.ToKwargs()
if err != nil {
return nil, nil, err
}
// Apply default fields if none specified
if _, hasFields := kwargs["fields"]; !hasFields {
kwargs["fields"] = DefaultPersonListFields
}
method, err := qb.ToMethod(false)
if err != nil {
return nil, nil, err
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: method,
CallID: newCallID(),
Kwargs: kwargs,
}
var resp models.PersonListResponse
if err := c.doRequest(ctx, reqBody, &resp); err != nil {
return nil, nil, errors.WithMessage(err, "failed to get persons")
}
return resp.Result, &resp.Meta, nil
}
// PersonCount counts using REAL Squirrel
// Example:
//
// qb := evateamclient.NewQueryBuilder().
// From(evateamclient.EntityPerson).
// Where(sq.Eq{"does_not_work": false})
// count, err := client.PersonCount(ctx, qb)
func (c *Client) PersonCount(
ctx context.Context,
qb *QueryBuilder,
) (int, error) {
kwargs, err := qb.ToKwargs()
if err != nil {
return 0, err
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: "CmfPerson.count",
CallID: newCallID(),
Kwargs: kwargs,
}
var resp struct {
JSONRPC string `json:"jsonrpc"`
Result int `json:"result"`
}
if err := c.doRequest(ctx, reqBody, &resp); err != nil {
return 0, errors.WithMessage(err, "failed to get person count")
}
return resp.Result, nil
}
// Backward compatible methods (using old API)
// Persons retrieves users with custom filters (backward compatible, deprecated)
// Recommended: use PersonsList with NewQueryBuilder() instead
func (c *Client) Persons(
ctx context.Context,
kwargs map[string]any,
) ([]models.Person, *models.Meta, error) {
if len(kwargs) == 0 {
kwargs = make(map[string]any)
}
if _, hasFields := kwargs["fields"]; !hasFields {
kwargs["fields"] = DefaultPersonListFields
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: "CmfPerson.list",
CallID: newCallID(),
Kwargs: kwargs,
}
var resp models.PersonListResponse
if err := c.doRequest(ctx, reqBody, &resp); err != nil {
return nil, nil, err
}
return resp.Result, &resp.Meta, nil
}
/**
* This file is part of the raoptimus/evateamclient.go library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/evateamclient.go/blob/master/LICENSE.md
* @link https://github.com/raoptimus/evateamclient.go
*/
package main
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
const (
httpReadHeaderTimeout = 10 * time.Second
httpShutdownTimeout = 5 * time.Second
)
type httpOpts struct {
Addr string
Path string
Stateless bool
JSONResponse bool
}
func runHTTPServer(ctx context.Context, logger *slog.Logger, server *mcp.Server, opts httpOpts) error {
if opts.Addr == "" {
return errors.New("http addr is required")
}
if opts.Path == "" {
opts.Path = "/mcp"
}
mcpHandler := mcp.NewStreamableHTTPHandler(
func(*http.Request) *mcp.Server { return server },
&mcp.StreamableHTTPOptions{
Stateless: opts.Stateless,
JSONResponse: opts.JSONResponse,
Logger: logger,
},
)
mux := http.NewServeMux()
mux.Handle(opts.Path, mcpHandler)
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
if _, err := w.Write([]byte("ok")); err != nil {
logger.Debug("healthz write failed", "err", err)
}
})
httpServer := &http.Server{
Addr: opts.Addr,
Handler: mux,
ReadHeaderTimeout: httpReadHeaderTimeout,
}
errCh := make(chan error, 1)
go func() {
logger.Info("MCP HTTP server listening", "addr", opts.Addr, "path", opts.Path)
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- err
return
}
errCh <- nil
}()
select {
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), httpShutdownTimeout)
defer cancel()
if err := httpServer.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("http server shutdown: %w", err)
}
<-errCh
return nil
case err := <-errCh:
if err != nil {
return fmt.Errorf("http server: %w", err)
}
return nil
}
}
/**
* This file is part of the raoptimus/evateamclient.go library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/evateamclient.go/blob/master/LICENSE.md
* @link https://github.com/raoptimus/evateamclient.go
*/
// Package main provides the MCP server for EVA Team API.
//
// This CLI application implements the Model Context Protocol (MCP)
// for interacting with EVA Team project management system.
//
// Configuration can be provided via command-line flags or environment variables.
// Flags take precedence over environment variables.
//
// Flags:
//
// --api-url, -u EVA Team API URL (env: EVA_API_URL) [required]
// --token, -t API authentication token (env: EVA_API_TOKEN) [required]
// --debug, -d Enable debug logging for API requests (env: EVA_DEBUG)
// --timeout API request timeout (env: EVA_TIMEOUT) [default: 30s]
// --transport Transport: stdio or http (env: MCP_TRANSPORT) [default: stdio]
// --http-addr HTTP listen address (env: MCP_HTTP_ADDR) [default: 127.0.0.1:8080]
// --http-path HTTP base path for MCP endpoint (env: MCP_HTTP_PATH) [default: /mcp]
// --http-stateless Run HTTP transport in stateless mode (env: MCP_HTTP_STATELESS)
// --http-json-response Return application/json instead of SSE (env: MCP_HTTP_JSON_RESPONSE)
//
// Usage (stdio, for Claude Desktop / Claude Code CLI):
//
// evateamclient-mcp --api-url="https://eva.example.com" --token="your-api-token"
//
// Usage (HTTP, for Claude.ai custom connector via tunnel):
//
// evateamclient-mcp --transport=http --http-addr=127.0.0.1:8080 \
// --api-url="https://eva.example.com" --token="your-api-token"
package main
import (
"context"
"fmt"
"log"
"log/slog"
"os"
"os/signal"
"syscall"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/raoptimus/evateamclient.go"
"github.com/raoptimus/evateamclient.go/pkg/evateamclient-mcp/tools"
"github.com/raoptimus/evateamclient.go/slogadapter"
"github.com/urfave/cli/v3"
)
const (
serverName = "evateamclient-mcp"
serverVersion = "1.0.0"
defaultRequestTimeout = 30 * time.Second
defaultHTTPAddr = "127.0.0.1:8080"
defaultHTTPPath = "/mcp"
transportStdio = "stdio"
transportHTTP = "http"
)
type transportConfig struct {
Transport string
HTTPAddr string
HTTPPath string
HTTPStateless bool
HTTPJSONResponse bool
}
func main() {
cfg := &evateamclient.Config{}
tcfg := &transportConfig{}
cmd := &cli.Command{
Name: serverName,
Usage: "MCP server for EVA Team API",
Version: serverVersion,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "api-url",
Aliases: []string{"u"},
Usage: "EVA Team API URL",
Sources: cli.EnvVars("EVA_API_URL"),
Required: true,
Destination: &cfg.BaseURL,
},
&cli.StringFlag{
Name: "token",
Aliases: []string{"t"},
Usage: "API authentication token",
Sources: cli.EnvVars("EVA_API_TOKEN"),
Required: true,
Destination: &cfg.APIToken,
},
&cli.BoolFlag{
Name: "debug",
Aliases: []string{"d"},
Usage: "Enable debug logging for API requests and MCP server",
Sources: cli.EnvVars("EVA_DEBUG"),
Destination: &cfg.Debug,
},
&cli.DurationFlag{
Name: "timeout",
Usage: "API request timeout",
Sources: cli.EnvVars("EVA_TIMEOUT"),
Value: defaultRequestTimeout,
Destination: &cfg.Timeout,
},
&cli.StringFlag{
Name: "transport",
Usage: "Transport: stdio or http",
Sources: cli.EnvVars("MCP_TRANSPORT"),
Value: transportStdio,
Destination: &tcfg.Transport,
},
&cli.StringFlag{
Name: "http-addr",
Usage: "HTTP listen address (used with --transport=http)",
Sources: cli.EnvVars("MCP_HTTP_ADDR"),
Value: defaultHTTPAddr,
Destination: &tcfg.HTTPAddr,
},
&cli.StringFlag{
Name: "http-path",
Usage: "HTTP base path for MCP endpoint (used with --transport=http)",
Sources: cli.EnvVars("MCP_HTTP_PATH"),
Value: defaultHTTPPath,
Destination: &tcfg.HTTPPath,
},
&cli.BoolFlag{
Name: "http-stateless",
Usage: "Run HTTP transport in stateless mode (used with --transport=http)",
Sources: cli.EnvVars("MCP_HTTP_STATELESS"),
Destination: &tcfg.HTTPStateless,
},
&cli.BoolFlag{
Name: "http-json-response",
Usage: "Return application/json instead of SSE (used with --transport=http)",
Sources: cli.EnvVars("MCP_HTTP_JSON_RESPONSE"),
Destination: &tcfg.HTTPJSONResponse,
},
},
Writer: os.Stderr,
ErrWriter: os.Stderr,
Action: func(ctx context.Context, cmd *cli.Command) error {
return runServer(ctx, cfg, tcfg)
},
}
if err := cmd.Run(context.Background(), os.Args); err != nil {
log.Fatalf("Fatal error: %v", err)
}
}
func runServer(ctx context.Context, cfg *evateamclient.Config, tcfg *transportConfig) error {
// Create logger
loggerLevel := slog.LevelWarn
if cfg.Debug {
loggerLevel = slog.LevelDebug
}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
Level: loggerLevel,
}))
// Create EVA Team client
evaClient, err := evateamclient.NewClient(cfg, evateamclient.WithLogger(slogadapter.New(logger)))
if err != nil {
return fmt.Errorf("failed to create EVA client: %w", err)
}
defer evaClient.Close()
// Create MCP server
server := mcp.NewServer(
&mcp.Implementation{
Name: serverName,
Version: serverVersion,
},
&mcp.ServerOptions{
Logger: logger,
Capabilities: &mcp.ServerCapabilities{
Tools: &mcp.ToolCapabilities{ListChanged: true},
},
},
)
// Register tools before starting the server
registry := tools.NewRegistry(evaClient)
registry.RegisterAll(server)
// Setup graceful shutdown
ctx, cancel := context.WithCancel(ctx)
defer cancel()
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigChan
cancel()
}()
switch tcfg.Transport {
case transportStdio, "":
if err := server.Run(ctx, &mcp.StdioTransport{}); err != nil {
return fmt.Errorf("server error: %w", err)
}
return nil
case transportHTTP:
return runHTTPServer(ctx, logger, server, httpOpts{
Addr: tcfg.HTTPAddr,
Path: tcfg.HTTPPath,
Stateless: tcfg.HTTPStateless,
JSONResponse: tcfg.HTTPJSONResponse,
})
default:
return fmt.Errorf("unknown transport %q (use %q or %q)", tcfg.Transport, transportStdio, transportHTTP)
}
}
/**
* This file is part of the raoptimus/evateamclient.go library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/evateamclient.go/blob/master/LICENSE.md
* @link https://github.com/raoptimus/evateamclient.go
*/
package tools
import (
"context"
sq "github.com/Masterminds/squirrel"
"github.com/raoptimus/evateamclient.go"
)
// CommentTools provides MCP tool handlers for comment operations.
type CommentTools struct {
client *evateamclient.Client
}
// NewCommentTools creates a new CommentTools instance.
func NewCommentTools(client *evateamclient.Client) *CommentTools {
return &CommentTools{client: client}
}
// CommentListInput represents input for eva_comment_list tool.
type CommentListInput struct {
QueryInput
TaskID string `json:"task_id,omitempty"`
TaskCode string `json:"task_code,omitempty"`
AuthorID string `json:"author_id,omitempty"`
}
// CommentList returns a list of comments.
func (c *CommentTools) CommentList(ctx context.Context, input *CommentListInput) (*ListResult, error) {
qb, err := BuildQuery(evateamclient.EntityComment, &input.QueryInput)
if err != nil {
return nil, WrapError("comment_list", err)
}
if input.TaskID != "" {
qb = qb.Where(sq.Eq{"task_id": input.TaskID})
}
if input.TaskCode != "" {
qb = qb.Where(sq.Eq{"task_id": "Task:" + input.TaskCode})
}
if input.AuthorID != "" {
qb = qb.Where(sq.Eq{"cmf_author_id": input.AuthorID})
}
comments, _, err := c.client.CommentsList(ctx, qb)
if err != nil {
return nil, WrapError("comment_list", err)
}
return &ListResult{
Items: toAnySlice(comments),
HasMore: len(comments) == input.Limit && input.Limit > 0,
}, nil
}
// CommentGetInput represents input for eva_comment_get tool.
type CommentGetInput struct {
ID string `json:"id"`
Fields []string `json:"fields,omitempty"`
}
// CommentGet retrieves a single comment.
func (c *CommentTools) CommentGet(ctx context.Context, input *CommentGetInput) (any, error) {
if input.ID == "" {
return nil, WrapError("comment_get", ErrInvalidInput)
}
comment, _, err := c.client.Comment(ctx, input.ID, input.Fields)
if err != nil {
return nil, WrapError("comment_get", err)
}
return comment, nil
}
// CommentCreateInput represents input for eva_comment_create tool.
type CommentCreateInput struct {
TaskID string `json:"task_id"`
Text string `json:"text"`
}
// CommentCreate creates a new comment.
func (c *CommentTools) CommentCreate(ctx context.Context, input *CommentCreateInput) (any, error) {
if input.TaskID == "" || input.Text == "" {
return nil, WrapError("comment_create", ErrInvalidInput)
}
comment, err := c.client.CommentCreate(ctx, input.TaskID, input.Text)
if err != nil {
return nil, WrapError("comment_create", err)
}
return comment, nil
}
// CommentUpdateInput represents input for eva_comment_update tool.
type CommentUpdateInput struct {
ID string `json:"id"`
Text string `json:"text"`
}
// CommentUpdate updates an existing comment.
func (c *CommentTools) CommentUpdate(ctx context.Context, input *CommentUpdateInput) (any, error) {
if input.ID == "" || input.Text == "" {
return nil, WrapError("comment_update", ErrInvalidInput)
}
comment, err := c.client.CommentUpdate(ctx, input.ID, input.Text)
if err != nil {
return nil, WrapError("comment_update", err)
}
return comment, nil
}
// CommentDeleteInput represents input for eva_comment_delete tool.
type CommentDeleteInput struct {
ID string `json:"id"`
}
// CommentDelete deletes a comment.
func (c *CommentTools) CommentDelete(ctx context.Context, input *CommentDeleteInput) (any, error) {
if input.ID == "" {
return nil, WrapError("comment_delete", ErrInvalidInput)
}
err := c.client.CommentDelete(ctx, input.ID)
if err != nil {
return nil, WrapError("comment_delete", err)
}
return map[string]bool{"success": true}, nil
}
// CommentCountInput represents input for eva_comment_count tool.
type CommentCountInput struct {
TaskID string `json:"task_id,omitempty"`
TaskCode string `json:"task_code,omitempty"`
AuthorID string `json:"author_id,omitempty"`
}
// CommentCount counts comments.
func (c *CommentTools) CommentCount(ctx context.Context, input *CommentCountInput) (*CountResult, error) {
qb := evateamclient.NewQueryBuilder().From(evateamclient.EntityComment)
if input.TaskID != "" {
qb = qb.Where(sq.Eq{"task_id": input.TaskID})
}
if input.TaskCode != "" {
qb = qb.Where(sq.Eq{"task_id": "Task:" + input.TaskCode})
}
if input.AuthorID != "" {
qb = qb.Where(sq.Eq{"cmf_author_id": input.AuthorID})
}
count, err := c.client.CommentCount(ctx, qb)
if err != nil {
return nil, WrapError("comment_count", err)
}
return &CountResult{Count: count}, nil
}
/**
* This file is part of the raoptimus/evateamclient.go library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/evateamclient.go/blob/master/LICENSE.md
* @link https://github.com/raoptimus/evateamclient.go
*/
package tools
import (
"fmt"
sq "github.com/Masterminds/squirrel"
"github.com/raoptimus/evateamclient.go"
)
const defaultPaginationLimit = 100 // Default limit for pagination
// BuildQuery converts QueryInput to evateamclient.QueryBuilder.
func BuildQuery(entity string, input *QueryInput) (*evateamclient.QueryBuilder, error) {
qb := evateamclient.NewQueryBuilder().From(entity)
if input == nil {
return qb, nil
}
// Apply fields projection
if len(input.Fields) > 0 {
qb = qb.Select(input.Fields...)
}
// Apply filters
for _, f := range input.Filters {
pred, err := filterToPredicate(f)
if err != nil {
return nil, err
}
qb = qb.Where(pred)
}
// Apply ordering
if len(input.OrderBy) > 0 {
qb = qb.OrderBy(input.OrderBy...)
}
// Apply pagination
if input.Limit > 0 {
qb = qb.Limit(uint64(input.Limit))
}
if input.Offset > 0 {
qb = qb.Offset(uint64(input.Offset))
}
// Include archived
if input.IncludeArchived {
qb = qb.IncludeArchived()
}
return qb, nil
}
// filterToPredicate converts a Filter to Squirrel predicate.
func filterToPredicate(f Filter) (any, error) {
switch f.Operator {
case "==", "=":
return sq.Eq{f.Field: f.Value}, nil
case "!=", "<>":
return sq.NotEq{f.Field: f.Value}, nil
case ">":
return sq.Gt{f.Field: f.Value}, nil
case ">=":
return sq.GtOrEq{f.Field: f.Value}, nil
case "<":
return sq.Lt{f.Field: f.Value}, nil
case "<=":
return sq.LtOrEq{f.Field: f.Value}, nil
case "LIKE", "like":
return sq.Like{f.Field: f.Value}, nil
case "contains":
// EVA-specific "contains" operator - pass as raw kwargs
return nil, fmt.Errorf("'contains' operator requires raw kwargs, use custom filter")
default:
return nil, fmt.Errorf("unsupported operator: %s", f.Operator)
}
}
// BuildKwargs converts QueryInput to raw kwargs map for EVA-specific operations.
func BuildKwargs(input *QueryInput) map[string]any {
kwargs := make(map[string]any)
if input == nil {
return kwargs
}
// Apply fields
if len(input.Fields) > 0 {
kwargs["fields"] = input.Fields
}
// Apply filters
if len(input.Filters) > 0 {
filters := make([][]any, 0, len(input.Filters))
for _, f := range input.Filters {
filters = append(filters, []any{f.Field, f.Operator, f.Value})
}
if len(filters) == 1 {
kwargs["filter"] = filters[0]
} else {
kwargs["filter"] = filters
}
}
// Apply ordering
if len(input.OrderBy) > 0 {
kwargs["order_by"] = input.OrderBy
}
// Apply pagination (EVA uses slice: [start, end])
if input.Limit > 0 || input.Offset > 0 {
start := input.Offset
end := input.Offset + input.Limit
if input.Limit == 0 {
end = start + defaultPaginationLimit // default limit
}
kwargs["slice"] = []int{start, end}
}
// Include archived
if input.IncludeArchived {
kwargs["include_archived"] = true
}
return kwargs
}
// toAnySlice converts typed slice to []any
func toAnySlice[T any](items []T) []any {
result := make([]any, len(items))
for i, item := range items {
result[i] = item
}
return result
}
/**
* This file is part of the raoptimus/evateamclient.go library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/evateamclient.go/blob/master/LICENSE.md
* @link https://github.com/raoptimus/evateamclient.go
*/
package tools
import (
"context"
sq "github.com/Masterminds/squirrel"
"github.com/raoptimus/evateamclient.go"
)
// DocumentTools provides MCP tool handlers for document operations.
type DocumentTools struct {
client *evateamclient.Client
}
// NewDocumentTools creates a new DocumentTools instance.
func NewDocumentTools(client *evateamclient.Client) *DocumentTools {
return &DocumentTools{client: client}
}
// DocumentListInput represents input for eva_document_list tool.
type DocumentListInput struct {
QueryInput
ProjectID string `json:"project_id,omitempty"`
}
// DocumentList returns a list of documents.
func (d *DocumentTools) DocumentList(ctx context.Context, input *DocumentListInput) (*ListResult, error) {
qb, err := BuildQuery(evateamclient.EntityDocument, &input.QueryInput)
if err != nil {
return nil, WrapError("document_list", err)
}
if input.ProjectID != "" {
qb = qb.Where(sq.Eq{"project_id": input.ProjectID})
}
docs, _, err := d.client.DocumentsList(ctx, qb)
if err != nil {
return nil, WrapError("document_list", err)
}
return &ListResult{
Items: toAnySlice(docs),
HasMore: len(docs) == input.Limit && input.Limit > 0,
}, nil
}
// DocumentGetInput represents input for eva_document_get tool.
type DocumentGetInput struct {
Code string `json:"code,omitempty"`
ID string `json:"id,omitempty"`
Fields []string `json:"fields,omitempty"`
}
// DocumentGet retrieves a single document.
func (d *DocumentTools) DocumentGet(ctx context.Context, input DocumentGetInput) (any, error) {
var qb *evateamclient.QueryBuilder
switch {
case input.Code != "":
qb = evateamclient.NewQueryBuilder().
Select(input.Fields...).
From(evateamclient.EntityDocument).
Where(sq.Eq{"code": input.Code}).
Limit(1)
case input.ID != "":
qb = evateamclient.NewQueryBuilder().
Select(input.Fields...).
From(evateamclient.EntityDocument).
Where(sq.Eq{"id": input.ID}).
Limit(1)
default:
return nil, WrapError("document_get", ErrInvalidInput)
}
doc, _, err := d.client.DocumentQuery(ctx, qb)
if err != nil {
return nil, WrapError("document_get", err)
}
return doc, nil
}
// DocumentPageTreeInput represents input for eva_document_page_tree tool.
type DocumentPageTreeInput struct {
NodeID string `json:"node_id"`
}
// DocumentPageTree retrieves the document page tree hierarchy.
func (d *DocumentTools) DocumentPageTree(ctx context.Context, input DocumentPageTreeInput) (*ListResult, error) {
if input.NodeID == "" {
return nil, WrapError("document_page_tree", ErrInvalidInput)
}
docs, err := d.client.DocumentPageTree(ctx, input.NodeID)
if err != nil {
return nil, WrapError("document_page_tree", err)
}
return &ListResult{
Items: toAnySlice(docs),
}, nil
}
// DocumentCreateInput represents input for eva_document_create tool.
type DocumentCreateInput struct {
Name string `json:"name"`
ProjectID string `json:"project_id"`
Text string `json:"text,omitempty"`
ParentID string `json:"parent_id,omitempty"`
}
// DocumentCreate creates a new document.
func (d *DocumentTools) DocumentCreate(ctx context.Context, input DocumentCreateInput) (any, error) {
params := evateamclient.DocumentCreateParams{
Name: input.Name,
ProjectID: input.ProjectID,
Text: input.Text,
ParentID: input.ParentID,
}
doc, err := d.client.DocumentCreate(ctx, params)
if err != nil {
return nil, WrapError("document_create", err)
}
return doc, nil
}
// DocumentUpdateInput represents input for eva_document_update tool.
type DocumentUpdateInput struct {
ID string `json:"id"`
Updates map[string]any `json:"updates"`
}
// DocumentUpdate updates an existing document.
func (d *DocumentTools) DocumentUpdate(ctx context.Context, input DocumentUpdateInput) (any, error) {
if input.ID == "" {
return nil, WrapError("document_update", ErrInvalidInput)
}
doc, err := d.client.DocumentUpdate(ctx, input.ID, input.Updates)
if err != nil {
return nil, WrapError("document_update", err)
}
return doc, nil
}
// DocumentDeleteInput represents input for eva_document_delete tool.
type DocumentDeleteInput struct {
ID string `json:"id"`
}
// DocumentDelete deletes a document.
func (d *DocumentTools) DocumentDelete(ctx context.Context, input DocumentDeleteInput) (any, error) {
if input.ID == "" {
return nil, WrapError("document_delete", ErrInvalidInput)
}
err := d.client.DocumentDelete(ctx, input.ID)
if err != nil {
return nil, WrapError("document_delete", err)
}
return map[string]bool{"success": true}, nil
}
// DocumentCountInput represents input for eva_document_count tool.
type DocumentCountInput struct {
ProjectID string `json:"project_id,omitempty"`
}
// DocumentCount counts documents.
func (d *DocumentTools) DocumentCount(ctx context.Context, input DocumentCountInput) (*CountResult, error) {
qb := evateamclient.NewQueryBuilder().From(evateamclient.EntityDocument)
if input.ProjectID != "" {
qb = qb.Where(sq.Eq{"project_id": input.ProjectID})
}
count, err := d.client.DocumentCount(ctx, qb)
if err != nil {
return nil, WrapError("document_count", err)
}
return &CountResult{Count: count}, nil
}
/**
* This file is part of the raoptimus/evateamclient.go library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/evateamclient.go/blob/master/LICENSE.md
* @link https://github.com/raoptimus/evateamclient.go
*/
package tools
import (
"context"
"github.com/raoptimus/evateamclient.go"
)
// EpicTools provides MCP tool handlers for epic operations.
type EpicTools struct {
client *evateamclient.Client
}
// NewEpicTools creates a new EpicTools instance.
func NewEpicTools(client *evateamclient.Client) *EpicTools {
return &EpicTools{client: client}
}
// EpicListInput represents input for eva_epic_list tool.
type EpicListInput struct {
QueryInput
ProjectID string `json:"project_id,omitempty"`
}
// EpicList returns a list of epics.
func (e *EpicTools) EpicList(ctx context.Context, input *EpicListInput) (*ListResult, error) {
// Build kwargs with logic_type.code filter
kwargs := BuildKwargs(&input.QueryInput)
var filters [][]any
if existingFilter, ok := kwargs["filter"].([][]any); ok {
filters = existingFilter
} else {
if singleFilter, ok := kwargs["filter"].([]any); ok {
filters = [][]any{singleFilter}
}
}
// Add epic filter
filters = append(filters, []any{"logic_type.code", "==", evateamclient.LogicTypeEpic})
if input.ProjectID != "" {
filters = append(filters, []any{"project_id", "==", input.ProjectID})
}
kwargs["filter"] = filters
// Set default fields if not specified
if _, ok := kwargs["fields"]; !ok {
kwargs["fields"] = evateamclient.DefaultEpicListFields
}
epics, _, err := e.client.Epics(ctx, kwargs)
if err != nil {
return nil, WrapError("epic_list", err)
}
return &ListResult{
Items: toAnySlice(epics),
HasMore: len(epics) == input.Limit && input.Limit > 0,
}, nil
}
// EpicGetInput represents input for eva_epic_get tool.
type EpicGetInput struct {
Code string `json:"code,omitempty"`
ID string `json:"id,omitempty"`
Fields []string `json:"fields,omitempty"`
}
// EpicGet retrieves a single epic.
func (e *EpicTools) EpicGet(ctx context.Context, input EpicGetInput) (any, error) {
switch {
case input.Code != "":
epic, _, err := e.client.Epic(ctx, input.Code, input.Fields)
if err != nil {
return nil, WrapError("epic_get", err)
}
return epic, nil
case input.ID != "":
epic, _, err := e.client.EpicByID(ctx, input.ID, input.Fields)
if err != nil {
return nil, WrapError("epic_get", err)
}
return epic, nil
default:
return nil, WrapError("epic_get", ErrInvalidInput)
}
}
// EpicCountInput represents input for eva_epic_count tool.
type EpicCountInput struct {
ProjectID string `json:"project_id,omitempty"`
}
// EpicCount counts epics.
func (e *EpicTools) EpicCount(ctx context.Context, input EpicCountInput) (*CountResult, error) {
// Build kwargs with logic_type.code filter
kwargs := make(map[string]any)
filters := [][]any{
{"logic_type.code", "==", evateamclient.LogicTypeEpic},
}
if input.ProjectID != "" {
filters = append(filters, []any{"project_id", "==", input.ProjectID})
}
kwargs["filter"] = filters
count, _, err := e.client.TasksCount(ctx, kwargs)
if err != nil {
return nil, WrapError("epic_count", err)
}
return &CountResult{Count: int(count)}, nil
}
/**
* This file is part of the raoptimus/evateamclient.go library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/evateamclient.go/blob/master/LICENSE.md
* @link https://github.com/raoptimus/evateamclient.go
*/
package tools
import (
"errors"
"fmt"
"strings"
)
// Common error types for MCP tools.
var (
ErrNotFound = errors.New("resource not found")
ErrInvalidInput = errors.New("invalid input")
ErrUnauthorized = errors.New("unauthorized")
ErrForbidden = errors.New("forbidden")
ErrInternalServer = errors.New("internal server error")
)
// WrapError wraps an error with context for MCP response.
func WrapError(operation string, err error) error {
if err == nil {
return nil
}
errMsg := err.Error()
// Check for common EVA RPC error patterns
switch {
case strings.Contains(errMsg, "not found"):
return fmt.Errorf("%s: %w", operation, ErrNotFound)
case strings.Contains(errMsg, "401") || strings.Contains(errMsg, "Unauthorized"):
return fmt.Errorf("%s: %w", operation, ErrUnauthorized)
case strings.Contains(errMsg, "403") || strings.Contains(errMsg, "Forbidden"):
return fmt.Errorf("%s: %w", operation, ErrForbidden)
case strings.Contains(errMsg, "validation") || strings.Contains(errMsg, "invalid"):
return fmt.Errorf("%s: %w: %s", operation, ErrInvalidInput, errMsg)
default:
return fmt.Errorf("%s: %w", operation, err)
}
}
// FormatToolError formats error for MCP tool response.
func FormatToolError(err error) string {
if err == nil {
return ""
}
switch {
case errors.Is(err, ErrNotFound):
return "Resource not found. Please check the ID or code and try again."
case errors.Is(err, ErrUnauthorized):
return "Authentication failed. Please check EVA_API_TOKEN."
case errors.Is(err, ErrForbidden):
return "Access denied. You don't have permission for this operation."
case errors.Is(err, ErrInvalidInput):
return fmt.Sprintf("Invalid input: %v", err)
default:
return fmt.Sprintf("Operation failed: %v", err)
}
}
/**
* This file is part of the raoptimus/evateamclient.go library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/evateamclient.go/blob/master/LICENSE.md
* @link https://github.com/raoptimus/evateamclient.go
*/
package tools
import (
"context"
sq "github.com/Masterminds/squirrel"
"github.com/raoptimus/evateamclient.go"
)
const (
listTypeSprint = "sprint"
listTypeRelease = "release"
)
// ListTools provides MCP tool handlers for list (sprint/release) operations.
type ListTools struct {
client *evateamclient.Client
}
// NewListTools creates a new ListTools instance.
func NewListTools(client *evateamclient.Client) *ListTools {
return &ListTools{client: client}
}
// ListListInput represents input for eva_list_list tool.
type ListListInput struct {
QueryInput
// Filter by project
ProjectID string `json:"project_id,omitempty"`
// Filter by status
StatusType string `json:"status_type,omitempty"`
// Filter by type: "sprint" or "release"
Type string `json:"type,omitempty"`
}
// ListList returns a list of lists (sprints/releases).
func (l *ListTools) ListList(ctx context.Context, input *ListListInput) (*ListResult, error) {
qb, err := BuildQuery(evateamclient.EntityList, &input.QueryInput)
if err != nil {
return nil, WrapError("list_list", err)
}
// Add project filter
if input.ProjectID != "" {
qb = qb.Where(sq.Eq{"project_id": input.ProjectID})
}
// Add status filter
if input.StatusType != "" {
qb = qb.Where(sq.Eq{"cache_status_type": input.StatusType})
}
// Add type filter (sprint/release based on code prefix)
switch input.Type {
case listTypeSprint:
qb = qb.Where(sq.Like{"code": evateamclient.ListCodePrefixSprint + "%"})
case listTypeRelease:
qb = qb.Where(sq.Like{"code": evateamclient.ListCodePrefixRelease + "%"})
}
lists, _, err := l.client.ListsList(ctx, qb)
if err != nil {
return nil, WrapError("list_list", err)
}
return &ListResult{
Items: toAnySlice(lists),
HasMore: len(lists) == input.Limit && input.Limit > 0,
}, nil
}
// ListGetInput represents input for eva_list_get tool.
type ListGetInput struct {
// List code (e.g., "SPR-001543", "REL-001641")
Code string `json:"code,omitempty"`
// List ID (e.g., "CmfList:uuid")
ID string `json:"id,omitempty"`
// Fields to return
Fields []string `json:"fields,omitempty"`
}
// ListGet retrieves a single list by code or ID.
func (l *ListTools) ListGet(ctx context.Context, input *ListGetInput) (any, error) {
var qb *evateamclient.QueryBuilder
switch {
case input.Code != "":
qb = evateamclient.NewQueryBuilder().
Select(input.Fields...).
From(evateamclient.EntityList).
Where(sq.Eq{"code": input.Code}).
Limit(1)
case input.ID != "":
qb = evateamclient.NewQueryBuilder().
Select(input.Fields...).
From(evateamclient.EntityList).
Where(sq.Eq{"id": input.ID}).
Limit(1)
default:
return nil, WrapError("list_get", ErrInvalidInput)
}
list, _, err := l.client.ListQuery(ctx, qb)
if err != nil {
return nil, WrapError("list_get", err)
}
return list, nil
}
// ListCreateInput represents input for eva_list_create tool.
type ListCreateInput struct {
Name string `json:"name"`
ParentID string `json:"parent_id"` // Project ID
Code string `json:"code,omitempty"`
StartDate string `json:"start_date,omitempty"`
EndDate string `json:"end_date,omitempty"`
Goal string `json:"goal,omitempty"`
}
// ListCreate creates a new list (sprint/release).
func (l *ListTools) ListCreate(ctx context.Context, input *ListCreateInput) (any, error) {
params := &evateamclient.ListCreateParams{
Name: input.Name,
ParentID: input.ParentID,
Code: input.Code,
StartDate: input.StartDate,
EndDate: input.EndDate,
Goal: input.Goal,
}
list, err := l.client.ListCreate(ctx, params)
if err != nil {
return nil, WrapError("list_create", err)
}
return list, nil
}
// ListUpdateInput represents input for eva_list_update tool.
type ListUpdateInput struct {
ID string `json:"id"`
Updates map[string]any `json:"updates"`
}
// ListUpdate updates an existing list.
func (l *ListTools) ListUpdate(ctx context.Context, input ListUpdateInput) (any, error) {
if input.ID == "" {
return nil, WrapError("list_update", ErrInvalidInput)
}
list, err := l.client.ListUpdate(ctx, input.ID, input.Updates)
if err != nil {
return nil, WrapError("list_update", err)
}
return list, nil
}
// ListCloseInput represents input for eva_list_close tool.
type ListCloseInput struct {
ID string `json:"id"`
}
// ListClose closes a list (sprint/release).
func (l *ListTools) ListClose(ctx context.Context, input ListCloseInput) (any, error) {
if input.ID == "" {
return nil, WrapError("list_close", ErrInvalidInput)
}
list, err := l.client.ListClose(ctx, input.ID)
if err != nil {
return nil, WrapError("list_close", err)
}
return list, nil
}
// ListDeleteInput represents input for eva_list_delete tool.
type ListDeleteInput struct {
ID string `json:"id"`
}
// ListDelete deletes a list.
func (l *ListTools) ListDelete(ctx context.Context, input ListDeleteInput) (any, error) {
if input.ID == "" {
return nil, WrapError("list_delete", ErrInvalidInput)
}
err := l.client.ListDelete(ctx, input.ID)
if err != nil {
return nil, WrapError("list_delete", err)
}
return map[string]bool{"success": true}, nil
}
// ListCountInput represents input for eva_list_count tool.
type ListCountInput struct {
ProjectID string `json:"project_id,omitempty"`
StatusType string `json:"status_type,omitempty"`
Type string `json:"type,omitempty"` // "sprint" or "release"
}
// ListCount counts lists.
func (l *ListTools) ListCount(ctx context.Context, input *ListCountInput) (*CountResult, error) {
qb := evateamclient.NewQueryBuilder().From(evateamclient.EntityList)
if input.ProjectID != "" {
qb = qb.Where(sq.Eq{"project_id": input.ProjectID})
}
if input.StatusType != "" {
qb = qb.Where(sq.Eq{"cache_status_type": input.StatusType})
}
switch input.Type {
case listTypeSprint:
qb = qb.Where(sq.Like{"code": evateamclient.ListCodePrefixSprint + "%"})
case listTypeRelease:
qb = qb.Where(sq.Like{"code": evateamclient.ListCodePrefixRelease + "%"})
}
count, err := l.client.ListCount(ctx, qb)
if err != nil {
return nil, WrapError("list_count", err)
}
return &CountResult{Count: count}, nil
}
// SprintList is an alias for ListList with type=sprint.
func (l *ListTools) SprintList(ctx context.Context, input *ListListInput) (*ListResult, error) {
input.Type = listTypeSprint
return l.ListList(ctx, input)
}
// SprintGet is an alias for ListGet (validates sprint prefix).
func (l *ListTools) SprintGet(ctx context.Context, input *ListGetInput) (any, error) {
return l.ListGet(ctx, input)
}
// ReleaseList is an alias for ListList with type=release.
func (l *ListTools) ReleaseList(ctx context.Context, input *ListListInput) (*ListResult, error) {
input.Type = listTypeRelease
return l.ListList(ctx, input)
}
// ReleaseGet is an alias for ListGet (validates release prefix).
func (l *ListTools) ReleaseGet(ctx context.Context, input *ListGetInput) (any, error) {
return l.ListGet(ctx, input)
}
/**
* This file is part of the raoptimus/evateamclient.go library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/evateamclient.go/blob/master/LICENSE.md
* @link https://github.com/raoptimus/evateamclient.go
*/
package tools
import (
"context"
sq "github.com/Masterminds/squirrel"
"github.com/raoptimus/evateamclient.go"
)
// LogicTypeTools provides MCP tool handlers for logic type operations.
type LogicTypeTools struct {
client *evateamclient.Client
}
// NewLogicTypeTools creates a new LogicTypeTools instance.
func NewLogicTypeTools(client *evateamclient.Client) *LogicTypeTools {
return &LogicTypeTools{client: client}
}
// LogicTypeListInput represents input for eva_logic_type_list tool.
type LogicTypeListInput struct {
QueryInput
// Optional filter by CMF model name (e.g. "CmfTask").
CmfModelName string `json:"cmf_model_name,omitempty"`
// Optional filter by exact code (e.g. "task.epic:default").
Code string `json:"code,omitempty"`
}
// LogicTypeList returns a list of logic types matching filters.
func (l *LogicTypeTools) LogicTypeList(ctx context.Context, input *LogicTypeListInput) (*ListResult, error) {
qb, err := BuildQuery(evateamclient.EntityLogicType, &input.QueryInput)
if err != nil {
return nil, WrapError("logic_type_list", err)
}
if input.CmfModelName != "" {
qb = qb.Where(sq.Eq{evateamclient.LogicTypeFieldCmfModelName: input.CmfModelName})
}
if input.Code != "" {
qb = qb.Where(sq.Eq{evateamclient.LogicTypeFieldCode: input.Code})
}
items, _, err := l.client.LogicTypeList(ctx, qb)
if err != nil {
return nil, WrapError("logic_type_list", err)
}
return &ListResult{
Items: toAnySlice(items),
HasMore: len(items) == input.Limit && input.Limit > 0,
}, nil
}
// LogicTypeGetInput represents input for eva_logic_type_get tool.
type LogicTypeGetInput struct {
// LogicType code (e.g. "task.epic:default").
Code string `json:"code"`
}
// LogicTypeGet retrieves a single logic type by code.
func (l *LogicTypeTools) LogicTypeGet(ctx context.Context, input *LogicTypeGetInput) (any, error) {
if input.Code == "" {
return nil, WrapError("logic_type_get", ErrInvalidInput)
}
lt, err := l.client.LogicTypeByCode(ctx, input.Code)
if err != nil {
return nil, WrapError("logic_type_get", err)
}
return lt, nil
}
/**
* This file is part of the raoptimus/evateamclient.go library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/evateamclient.go/blob/master/LICENSE.md
* @link https://github.com/raoptimus/evateamclient.go
*/
package tools
import (
"context"
sq "github.com/Masterminds/squirrel"
"github.com/raoptimus/evateamclient.go"
)
// PersonTools provides MCP tool handlers for person operations.
type PersonTools struct {
client *evateamclient.Client
}
// NewPersonTools creates a new PersonTools instance.
func NewPersonTools(client *evateamclient.Client) *PersonTools {
return &PersonTools{client: client}
}
// PersonListInput represents input for eva_person_list tool.
type PersonListInput struct {
QueryInput
OnVacation *bool `json:"on_vacation,omitempty"`
DoesNotWork *bool `json:"does_not_work,omitempty"`
}
// PersonList returns a list of persons.
func (p *PersonTools) PersonList(ctx context.Context, input *PersonListInput) (*ListResult, error) {
qb, err := BuildQuery(evateamclient.EntityPerson, &input.QueryInput)
if err != nil {
return nil, WrapError("person_list", err)
}
if input.OnVacation != nil {
qb = qb.Where(sq.Eq{"on_vacation": *input.OnVacation})
}
if input.DoesNotWork != nil {
qb = qb.Where(sq.Eq{"does_not_work": *input.DoesNotWork})
}
persons, _, err := p.client.PersonsList(ctx, qb)
if err != nil {
return nil, WrapError("person_list", err)
}
return &ListResult{
Items: toAnySlice(persons),
HasMore: len(persons) == input.Limit && input.Limit > 0,
}, nil
}
// PersonGetInput represents input for eva_person_get tool.
type PersonGetInput struct {
ID string `json:"id,omitempty"`
Login string `json:"login,omitempty"`
Email string `json:"email,omitempty"`
Fields []string `json:"fields,omitempty"`
}
// PersonGet retrieves a single person.
func (p *PersonTools) PersonGet(ctx context.Context, input PersonGetInput) (any, error) {
var qb *evateamclient.QueryBuilder
switch {
case input.ID != "":
qb = evateamclient.NewQueryBuilder().
Select(input.Fields...).
From(evateamclient.EntityPerson).
Where(sq.Eq{"id": input.ID}).
Limit(1)
case input.Login != "":
qb = evateamclient.NewQueryBuilder().
Select(input.Fields...).
From(evateamclient.EntityPerson).
Where(sq.Eq{"login": input.Login}).
Limit(1)
case input.Email != "":
qb = evateamclient.NewQueryBuilder().
Select(input.Fields...).
From(evateamclient.EntityPerson).
Where(sq.Eq{"email": input.Email}).
Limit(1)
default:
return nil, WrapError("person_get", ErrInvalidInput)
}
person, _, err := p.client.PersonQuery(ctx, qb)
if err != nil {
return nil, WrapError("person_get", err)
}
return person, nil
}
// PersonCountInput represents input for eva_person_count tool.
type PersonCountInput struct {
OnVacation *bool `json:"on_vacation,omitempty"`
DoesNotWork *bool `json:"does_not_work,omitempty"`
}
// PersonCount counts persons.
func (p *PersonTools) PersonCount(ctx context.Context, input PersonCountInput) (*CountResult, error) {
qb := evateamclient.NewQueryBuilder().From(evateamclient.EntityPerson)
if input.OnVacation != nil {
qb = qb.Where(sq.Eq{"on_vacation": *input.OnVacation})
}
if input.DoesNotWork != nil {
qb = qb.Where(sq.Eq{"does_not_work": *input.DoesNotWork})
}
count, err := p.client.PersonCount(ctx, qb)
if err != nil {
return nil, WrapError("person_count", err)
}
return &CountResult{Count: count}, nil
}
/**
* This file is part of the raoptimus/evateamclient.go library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/evateamclient.go/blob/master/LICENSE.md
* @link https://github.com/raoptimus/evateamclient.go
*/
package tools
import (
"context"
sq "github.com/Masterminds/squirrel"
"github.com/raoptimus/evateamclient.go"
)
// ProjectTools provides MCP tool handlers for project operations.
type ProjectTools struct {
client *evateamclient.Client
}
// NewProjectTools creates a new ProjectTools instance.
func NewProjectTools(client *evateamclient.Client) *ProjectTools {
return &ProjectTools{client: client}
}
// ProjectListInput represents input for eva_project_list tool.
type ProjectListInput struct {
QueryInput
// Filter by system projects
System *bool `json:"system,omitempty"`
}
// ProjectList returns a list of projects.
func (p *ProjectTools) ProjectList(ctx context.Context, input *ProjectListInput) (*ListResult, error) {
qb, err := BuildQuery(evateamclient.EntityProject, &input.QueryInput)
if err != nil {
return nil, WrapError("project_list", err)
}
// Add system filter if specified
if input.System != nil {
qb = qb.Where(sq.Eq{"system": *input.System})
}
projects, _, err := p.client.ProjectsList(ctx, qb)
if err != nil {
return nil, WrapError("project_list", err)
}
return &ListResult{
Items: toAnySlice(projects),
HasMore: len(projects) == input.Limit && input.Limit > 0,
}, nil
}
// ProjectGetInput represents input for eva_project_get tool.
type ProjectGetInput struct {
// Project code (e.g., "PROJ")
Code string `json:"code,omitempty"`
// Project ID (e.g., "CmfProject:uuid")
ID string `json:"id,omitempty"`
// Fields to return
Fields []string `json:"fields,omitempty"`
}
// ProjectGet retrieves a single project by code or ID.
func (p *ProjectTools) ProjectGet(ctx context.Context, input *ProjectGetInput) (any, error) {
var qb *evateamclient.QueryBuilder
switch {
case input.Code != "":
qb = evateamclient.NewQueryBuilder().
Select(input.Fields...).
From(evateamclient.EntityProject).
Where(sq.Eq{"code": input.Code}).
Limit(1)
case input.ID != "":
qb = evateamclient.NewQueryBuilder().
Select(input.Fields...).
From(evateamclient.EntityProject).
Where(sq.Eq{"id": input.ID}).
Limit(1)
default:
return nil, WrapError("project_get", ErrInvalidInput)
}
project, _, err := p.client.ProjectQuery(ctx, qb)
if err != nil {
return nil, WrapError("project_get", err)
}
return project, nil
}
// ProjectCreateInput represents input for eva_project_create tool.
type ProjectCreateInput struct {
Code string `json:"code"`
Name string `json:"name"`
Text string `json:"text,omitempty"`
WorkflowID string `json:"workflow_id,omitempty"`
Executors []string `json:"executors,omitempty"`
Admins []string `json:"admins,omitempty"`
}
// ProjectCreate creates a new project.
func (p *ProjectTools) ProjectCreate(ctx context.Context, input *ProjectCreateInput) (any, error) {
params := &evateamclient.ProjectCreateParams{
Code: input.Code,
Name: input.Name,
Text: input.Text,
WorkflowID: input.WorkflowID,
Executors: input.Executors,
Admins: input.Admins,
}
project, err := p.client.ProjectCreate(ctx, params)
if err != nil {
return nil, WrapError("project_create", err)
}
return project, nil
}
// ProjectUpdateInput represents input for eva_project_update tool.
type ProjectUpdateInput struct {
ID string `json:"id"`
Updates map[string]any `json:"updates"`
}
// ProjectUpdate updates an existing project.
func (p *ProjectTools) ProjectUpdate(ctx context.Context, input ProjectUpdateInput) (any, error) {
if input.ID == "" {
return nil, WrapError("project_update", ErrInvalidInput)
}
project, err := p.client.ProjectUpdate(ctx, input.ID, input.Updates)
if err != nil {
return nil, WrapError("project_update", err)
}
return project, nil
}
// ProjectDeleteInput represents input for eva_project_delete tool.
type ProjectDeleteInput struct {
ID string `json:"id"`
}
// ProjectDelete deletes a project.
func (p *ProjectTools) ProjectDelete(ctx context.Context, input ProjectDeleteInput) (any, error) {
if input.ID == "" {
return nil, WrapError("project_delete", ErrInvalidInput)
}
err := p.client.ProjectDelete(ctx, input.ID)
if err != nil {
return nil, WrapError("project_delete", err)
}
return map[string]bool{"success": true}, nil
}
// ProjectAddExecutorInput represents input for eva_project_add_executor tool.
type ProjectAddExecutorInput struct {
ProjectID string `json:"project_id"`
PersonID string `json:"person_id"`
}
// ProjectAddExecutor adds an executor to a project.
func (p *ProjectTools) ProjectAddExecutor(ctx context.Context, input ProjectAddExecutorInput) (any, error) {
if input.ProjectID == "" || input.PersonID == "" {
return nil, WrapError("project_add_executor", ErrInvalidInput)
}
err := p.client.ProjectAddExecutor(ctx, input.ProjectID, input.PersonID)
if err != nil {
return nil, WrapError("project_add_executor", err)
}
return map[string]bool{"success": true}, nil
}
// ProjectRemoveExecutorInput represents input for eva_project_remove_executor tool.
type ProjectRemoveExecutorInput struct {
ProjectID string `json:"project_id"`
PersonID string `json:"person_id"`
}
// ProjectRemoveExecutor removes an executor from a project.
func (p *ProjectTools) ProjectRemoveExecutor(ctx context.Context, input ProjectRemoveExecutorInput) (any, error) {
if input.ProjectID == "" || input.PersonID == "" {
return nil, WrapError("project_remove_executor", ErrInvalidInput)
}
err := p.client.ProjectRemoveExecutor(ctx, input.ProjectID, input.PersonID)
if err != nil {
return nil, WrapError("project_remove_executor", err)
}
return map[string]bool{"success": true}, nil
}
// ProjectCountInput represents input for eva_project_count tool.
type ProjectCountInput struct {
System *bool `json:"system,omitempty"`
}
// ProjectCount counts projects.
func (p *ProjectTools) ProjectCount(ctx context.Context, input ProjectCountInput) (*CountResult, error) {
qb := evateamclient.NewQueryBuilder().From(evateamclient.EntityProject)
if input.System != nil {
qb = qb.Where(sq.Eq{"system": *input.System})
}
count, err := p.client.ProjectCount(ctx, qb)
if err != nil {
return nil, WrapError("project_count", err)
}
return &CountResult{Count: count}, nil
}
/**
* This file is part of the raoptimus/evateamclient.go library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/evateamclient.go/blob/master/LICENSE.md
* @link https://github.com/raoptimus/evateamclient.go
*/
package tools
import (
"context"
"encoding/json"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/raoptimus/evateamclient.go"
)
func boolPtr(b bool) *bool { return &b }
var (
readOnlyAnnotations = &mcp.ToolAnnotations{
ReadOnlyHint: true,
}
writeAnnotations = &mcp.ToolAnnotations{
DestructiveHint: boolPtr(false),
OpenWorldHint: boolPtr(true),
}
idempotentWriteAnnotations = &mcp.ToolAnnotations{
DestructiveHint: boolPtr(false),
IdempotentHint: true,
OpenWorldHint: boolPtr(true),
}
destructiveAnnotations = &mcp.ToolAnnotations{
DestructiveHint: boolPtr(true),
OpenWorldHint: boolPtr(true),
}
)
// Registry holds all tool handlers.
type Registry struct {
Task *TaskTools
Project *ProjectTools
List *ListTools
Document *DocumentTools
Person *PersonTools
TimeLog *TimeLogTools
Comment *CommentTools
Epic *EpicTools
TaskLink *TaskLinkTools
StatusHistory *StatusHistoryTools
Stats *StatsTools
LogicType *LogicTypeTools
Tag *TagTools
}
// NewRegistry creates a new Registry with all tools initialized.
func NewRegistry(client *evateamclient.Client) *Registry {
return &Registry{
Task: NewTaskTools(client),
Project: NewProjectTools(client),
List: NewListTools(client),
Document: NewDocumentTools(client),
Person: NewPersonTools(client),
TimeLog: NewTimeLogTools(client),
Comment: NewCommentTools(client),
Epic: NewEpicTools(client),
TaskLink: NewTaskLinkTools(client),
StatusHistory: NewStatusHistoryTools(client),
Stats: NewStatsTools(client),
LogicType: NewLogicTypeTools(client),
Tag: NewTagTools(client),
}
}
// wrapHandler wraps a typed handler function to work with MCP's generic interface.
func wrapHandler[In, Out any](handler func(context.Context, In) (Out, error)) func(context.Context, *mcp.CallToolRequest, In) (*mcp.CallToolResult, Out, error) {
return func(ctx context.Context, req *mcp.CallToolRequest, args In) (*mcp.CallToolResult, Out, error) {
result, err := handler(ctx, args)
if err != nil {
var zero Out
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: FormatToolError(err)},
},
IsError: true,
}, zero, nil
}
// Serialize result to JSON for text content
jsonBytes, jsonErr := json.MarshalIndent(result, "", " ")
if jsonErr != nil {
var zero Out
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: "Failed to serialize result: " + jsonErr.Error()},
},
IsError: true,
},
zero,
jsonErr
}
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: string(jsonBytes)},
},
},
result,
nil
}
}
// RegisterAll registers all tools with the MCP server.
func (r *Registry) RegisterAll(server *mcp.Server) {
// Task tools
mcp.AddTool(server, &mcp.Tool{
Name: "eva_task_list",
Description: "List tasks with optional filters (project, status, sprint, responsible)",
Annotations: readOnlyAnnotations,
}, wrapHandler(r.Task.TaskList))
mcp.AddTool(server, &mcp.Tool{
Name: "eva_task_get",
Description: "Get a single task by code (e.g., 'PROJ-123') or ID",
Annotations: readOnlyAnnotations,
}, wrapHandler(r.Task.TaskGet))
mcp.AddTool(server, &mcp.Tool{
Name: "eva_task_create",
Description: "Create a new task. " +
"project_id accepts a project ID or code (e.g. 'epud'). " +
"epic and parent_task accept the parent's task ID. " +
"logic_type_id is required to set the task type — resolve a code via eva_logic_type_get. " +
"tags accepts tag codes (e.g. 'TAG-000004') — use eva_tag_list to find available tags.",
Annotations: writeAnnotations,
}, wrapHandler(r.Task.TaskCreate))
mcp.AddTool(server, &mcp.Tool{
Name: "eva_task_update",
Description: "Update an existing task. " +
"Pass fields to change in updates (e.g. name, priority, deadline). " +
"To set tags, pass tags as tag codes (e.g. 'TAG-000004') — replaces existing tags. " +
"Use eva_tag_list to find available tags.",
Annotations: idempotentWriteAnnotations,
}, wrapHandler(r.Task.TaskUpdate))
mcp.AddTool(server, &mcp.Tool{
Name: "eva_task_delete",
Description: "Delete a task",
Annotations: destructiveAnnotations,
}, wrapHandler(r.Task.TaskDelete))
mcp.AddTool(server, &mcp.Tool{
Name: "eva_task_update_status",
Description: "Update task status (OPEN, IN_PROGRESS, CLOSED)",
Annotations: idempotentWriteAnnotations,
}, wrapHandler(r.Task.TaskUpdateStatus))
mcp.AddTool(server, &mcp.Tool{
Name: "eva_task_archive",
Description: "Archive a task (soft delete)",
Annotations: destructiveAnnotations,
}, wrapHandler(r.Task.TaskArchive))
mcp.AddTool(server, &mcp.Tool{
Name: "eva_task_count",
Description: "Count tasks matching filters",
Annotations: readOnlyAnnotations,
}, wrapHandler(r.Task.TaskCount))
// Project tools
mcp.AddTool(server, &mcp.Tool{
Name: "eva_project_list",
Description: "List projects",
Annotations: readOnlyAnnotations,
}, wrapHandler(r.Project.ProjectList))
mcp.AddTool(server, &mcp.Tool{
Name: "eva_project_get",
Description: "Get a single project by code or ID",
Annotations: readOnlyAnnotations,
}, wrapHandler(r.Project.ProjectGet))
mcp.AddTool(server, &mcp.Tool{
Name: "eva_project_create",
Description: "Create a new project",
Annotations: writeAnnotations,
}, wrapHandler(r.Project.ProjectCreate))
mcp.AddTool(server, &mcp.Tool{
Name: "eva_project_update",
Description: "Update an existing project",
Annotations: idempotentWriteAnnotations,
}, wrapHandler(r.Project.ProjectUpdate))
mcp.AddTool(server, &mcp.Tool{
Name: "eva_project_delete",
Description: "Delete a project",
Annotations: destructiveAnnotations,
}, wrapHandler(r.Project.ProjectDelete))
mcp.AddTool(server, &mcp.Tool{
Name: "eva_project_add_executor",
Description: "Add an executor to a project",
Annotations: writeAnnotations,
}, wrapHandler(r.Project.ProjectAddExecutor))
mcp.AddTool(server, &mcp.Tool{
Name: "eva_project_remove_executor",
Description: "Remove an executor from a project",
Annotations: writeAnnotations,
}, wrapHandler(r.Project.ProjectRemoveExecutor))
mcp.AddTool(server, &mcp.Tool{
Name: "eva_project_count",
Description: "Count projects",
Annotations: readOnlyAnnotations,
}, wrapHandler(r.Project.ProjectCount))
// List tools (sprints/releases)
mcp.AddTool(server, &mcp.Tool{
Name: "eva_list_list",
Description: "List all lists (sprints and releases) with optional filters",
Annotations: readOnlyAnnotations,
}, wrapHandler(r.List.ListList))
mcp.AddTool(server, &mcp.Tool{
Name: "eva_list_get",
Description: "Get a single list by code (e.g., 'SPR-001543') or ID",
Annotations: readOnlyAnnotations,
}, wrapHandler(r.List.ListGet))
mcp.AddTool(server, &mcp.Tool{
Name: "eva_list_create",
Description: "Create a new list (sprint/release)",
Annotations: writeAnnotations,
}, wrapHandler(r.List.ListCreate))
mcp.AddTool(server, &mcp.Tool{
Name: "eva_list_update",
Description: "Update an existing list",
Annotations: idempotentWriteAnnotations,
}, wrapHandler(r.List.ListUpdate))
mcp.AddTool(server, &mcp.Tool{
Name: "eva_list_close",
Description: "Close a list (sprint/release)",
Annotations: destructiveAnnotations,
}, wrapHandler(r.List.ListClose))
mcp.AddTool(server, &mcp.Tool{
Name: "eva_list_delete",
Description: "Delete a list",
Annotations: destructiveAnnotations,
}, wrapHandler(r.List.ListDelete))
mcp.AddTool(server, &mcp.Tool{
Name: "eva_list_count",
Description: "Count lists",
Annotations: readOnlyAnnotations,
}, wrapHandler(r.List.ListCount))
// Sprint aliases
mcp.AddTool(server, &mcp.Tool{
Name: "eva_sprint_list",
Description: "List sprints (alias for eva_list_list with type=sprint)",
Annotations: readOnlyAnnotations,
}, wrapHandler(r.List.SprintList))
mcp.AddTool(server, &mcp.Tool{
Name: "eva_sprint_get",
Description: "Get a single sprint by code (e.g., 'SPR-001543')",
Annotations: readOnlyAnnotations,
}, wrapHandler(r.List.SprintGet))
// Release aliases
mcp.AddTool(server, &mcp.Tool{
Name: "eva_release_list",
Description: "List releases (alias for eva_list_list with type=release)",
Annotations: readOnlyAnnotations,
}, wrapHandler(r.List.ReleaseList))
mcp.AddTool(server, &mcp.Tool{
Name: "eva_release_get",
Description: "Get a single release by code (e.g., 'REL-001641')",
Annotations: readOnlyAnnotations,
}, wrapHandler(r.List.ReleaseGet))
// Document tools
mcp.AddTool(server, &mcp.Tool{
Name: "eva_document_list",
Description: "List documents",
Annotations: readOnlyAnnotations,
}, wrapHandler(r.Document.DocumentList))
mcp.AddTool(server, &mcp.Tool{
Name: "eva_document_get",
Description: "Get a single document by code or ID",
Annotations: readOnlyAnnotations,
}, wrapHandler(r.Document.DocumentGet))
mcp.AddTool(server, &mcp.Tool{
Name: "eva_document_create",
Description: "Create a new document",
Annotations: writeAnnotations,
}, wrapHandler(r.Document.DocumentCreate))
mcp.AddTool(server, &mcp.Tool{
Name: "eva_document_update",
Description: "Update an existing document",
Annotations: idempotentWriteAnnotations,
}, wrapHandler(r.Document.DocumentUpdate))
mcp.AddTool(server, &mcp.Tool{
Name: "eva_document_delete",
Description: "Delete a document",
Annotations: destructiveAnnotations,
}, wrapHandler(r.Document.DocumentDelete))
mcp.AddTool(server, &mcp.Tool{
Name: "eva_document_count",
Description: "Count documents",
Annotations: readOnlyAnnotations,
}, wrapHandler(r.Document.DocumentCount))
mcp.AddTool(server, &mcp.Tool{
Name: "eva_document_page_tree",
Description: "Get document page tree hierarchy by root node ID. Returns flat list with parent_id and tree_node_is_branch for building tree structure",
Annotations: readOnlyAnnotations,
}, wrapHandler(r.Document.DocumentPageTree))
// Person tools
mcp.AddTool(server, &mcp.Tool{
Name: "eva_person_list",
Description: "List persons (users)",
Annotations: readOnlyAnnotations,
}, wrapHandler(r.Person.PersonList))
mcp.AddTool(server, &mcp.Tool{
Name: "eva_person_get",
Description: "Get a single person by ID, login, or email",
Annotations: readOnlyAnnotations,
}, wrapHandler(r.Person.PersonGet))
mcp.AddTool(server, &mcp.Tool{
Name: "eva_person_count",
Description: "Count persons",
Annotations: readOnlyAnnotations,
}, wrapHandler(r.Person.PersonCount))
// TimeLog tools
mcp.AddTool(server, &mcp.Tool{
Name: "eva_timelog_list",
Description: "List time log entries",
Annotations: readOnlyAnnotations,
}, wrapHandler(r.TimeLog.TimeLogList))
mcp.AddTool(server, &mcp.Tool{
Name: "eva_timelog_get",
Description: "Get a single time log entry by ID",
Annotations: readOnlyAnnotations,
}, wrapHandler(r.TimeLog.TimeLogGet))
mcp.AddTool(server, &mcp.Tool{
Name: "eva_timelog_create",
Description: "Create a new time log entry (time_spent in minutes)",
Annotations: writeAnnotations,
}, wrapHandler(r.TimeLog.TimeLogCreate))
mcp.AddTool(server, &mcp.Tool{
Name: "eva_timelog_update",
Description: "Update an existing time log entry",
Annotations: idempotentWriteAnnotations,
}, wrapHandler(r.TimeLog.TimeLogUpdate))
mcp.AddTool(server, &mcp.Tool{
Name: "eva_timelog_delete",
Description: "Delete a time log entry",
Annotations: destructiveAnnotations,
}, wrapHandler(r.TimeLog.TimeLogDelete))
mcp.AddTool(server, &mcp.Tool{
Name: "eva_timelog_count",
Description: "Count time log entries",
Annotations: readOnlyAnnotations,
}, wrapHandler(r.TimeLog.TimeLogCount))
// Comment tools
mcp.AddTool(server, &mcp.Tool{
Name: "eva_comment_list",
Description: "List comments",
Annotations: readOnlyAnnotations,
}, wrapHandler(r.Comment.CommentList))
mcp.AddTool(server, &mcp.Tool{
Name: "eva_comment_get",
Description: "Get a single comment by ID",
Annotations: readOnlyAnnotations,
}, wrapHandler(r.Comment.CommentGet))
mcp.AddTool(server, &mcp.Tool{
Name: "eva_comment_create",
Description: "Create a new comment on a task",
Annotations: writeAnnotations,
}, wrapHandler(r.Comment.CommentCreate))
mcp.AddTool(server, &mcp.Tool{
Name: "eva_comment_update",
Description: "Update an existing comment",
Annotations: idempotentWriteAnnotations,
}, wrapHandler(r.Comment.CommentUpdate))
mcp.AddTool(server, &mcp.Tool{
Name: "eva_comment_delete",
Description: "Delete a comment",
Annotations: destructiveAnnotations,
}, wrapHandler(r.Comment.CommentDelete))
mcp.AddTool(server, &mcp.Tool{
Name: "eva_comment_count",
Description: "Count comments",
Annotations: readOnlyAnnotations,
}, wrapHandler(r.Comment.CommentCount))
// Epic tools
mcp.AddTool(server, &mcp.Tool{
Name: "eva_epic_list",
Description: "List epics",
Annotations: readOnlyAnnotations,
}, wrapHandler(r.Epic.EpicList))
mcp.AddTool(server, &mcp.Tool{
Name: "eva_epic_get",
Description: "Get a single epic by code or ID",
Annotations: readOnlyAnnotations,
}, wrapHandler(r.Epic.EpicGet))
mcp.AddTool(server, &mcp.Tool{
Name: "eva_epic_count",
Description: "Count epics",
Annotations: readOnlyAnnotations,
}, wrapHandler(r.Epic.EpicCount))
// TaskLink tools
mcp.AddTool(server, &mcp.Tool{
Name: "eva_tasklink_list",
Description: "List task links (relationships between tasks)",
Annotations: readOnlyAnnotations,
}, wrapHandler(r.TaskLink.TaskLinkList))
mcp.AddTool(server, &mcp.Tool{
Name: "eva_tasklink_get",
Description: "Get a single task link by ID",
Annotations: readOnlyAnnotations,
}, wrapHandler(r.TaskLink.TaskLinkGet))
mcp.AddTool(server, &mcp.Tool{
Name: "eva_tasklink_create",
Description: "Create a new task link",
Annotations: writeAnnotations,
}, wrapHandler(r.TaskLink.TaskLinkCreate))
mcp.AddTool(server, &mcp.Tool{
Name: "eva_tasklink_delete",
Description: "Delete a task link",
Annotations: destructiveAnnotations,
}, wrapHandler(r.TaskLink.TaskLinkDelete))
mcp.AddTool(server, &mcp.Tool{
Name: "eva_tasklink_count",
Description: "Count task links",
Annotations: readOnlyAnnotations,
}, wrapHandler(r.TaskLink.TaskLinkCount))
// StatusHistory tools
mcp.AddTool(server, &mcp.Tool{
Name: "eva_statushistory_list",
Description: "List status history entries",
Annotations: readOnlyAnnotations,
}, wrapHandler(r.StatusHistory.StatusHistoryList))
mcp.AddTool(server, &mcp.Tool{
Name: "eva_statushistory_get",
Description: "Get a single status history entry by ID",
Annotations: readOnlyAnnotations,
}, wrapHandler(r.StatusHistory.StatusHistoryGet))
mcp.AddTool(server, &mcp.Tool{
Name: "eva_statushistory_count",
Description: "Count status history entries",
Annotations: readOnlyAnnotations,
}, wrapHandler(r.StatusHistory.StatusHistoryCount))
// Stats tools
mcp.AddTool(server, &mcp.Tool{
Name: "eva_stats_project",
Description: "Get project statistics (total tasks, open tasks, active sprints, users)",
Annotations: readOnlyAnnotations,
}, wrapHandler(r.Stats.ProjectStats))
mcp.AddTool(server, &mcp.Tool{
Name: "eva_stats_sprint",
Description: "Get sprint statistics (total tasks, tasks by status)",
Annotations: readOnlyAnnotations,
}, wrapHandler(r.Stats.SprintStats))
mcp.AddTool(server, &mcp.Tool{
Name: "eva_stats_timespent",
Description: "Get time spent report grouped by person and task",
Annotations: readOnlyAnnotations,
}, wrapHandler(r.Stats.TimeSpentStats))
mcp.AddTool(server, &mcp.Tool{
Name: "eva_stats_sprint_executors_kpi",
Description: "Get KPI of closed sprint tasks by executor (requires project_code; if sprint_code is empty, aggregates across all project sprints; excludes tasks added during sprint)",
Annotations: readOnlyAnnotations,
}, wrapHandler(r.Stats.SprintExecutorsKPI))
// LogicType tools
mcp.AddTool(server, &mcp.Tool{
Name: "eva_logic_type_list",
Description: "List logic types (task subtypes like epic/story/task/bug). Filter by cmf_model_name (e.g. 'CmfTask') or code (e.g. 'task.epic:default')",
Annotations: readOnlyAnnotations,
}, wrapHandler(r.LogicType.LogicTypeList))
mcp.AddTool(server, &mcp.Tool{
Name: "eva_logic_type_get",
Description: "Get a single logic type by code (e.g. 'task.epic:default', 'task.userstory:story', 'task.agile:task', 'task.bug:default'). Returns LogicType with ID for use as logic_type_id when creating tasks",
Annotations: readOnlyAnnotations,
}, wrapHandler(r.LogicType.LogicTypeGet))
// Tag tools
mcp.AddTool(server, &mcp.Tool{
Name: "eva_tag_list",
Description: "List tags available for tasks. Returns tag code (e.g. 'TAG-000004') and name/aliases. Use tag code in the tags field of eva_task_create. Filter by project_id or name.",
Annotations: readOnlyAnnotations,
}, wrapHandler(r.Tag.TagList))
}
/**
* This file is part of the raoptimus/evateamclient.go library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/evateamclient.go/blob/master/LICENSE.md
* @link https://github.com/raoptimus/evateamclient.go
*/
package tools
import (
"context"
"time"
"github.com/raoptimus/evateamclient.go"
)
// StatsTools provides MCP tool handlers for statistics operations.
type StatsTools struct {
client *evateamclient.Client
}
// NewStatsTools creates a new StatsTools instance.
func NewStatsTools(client *evateamclient.Client) *StatsTools {
return &StatsTools{client: client}
}
// ProjectStatsInput represents input for eva_stats_project tool.
type ProjectStatsInput struct {
ProjectID string `json:"project_id"`
}
// ProjectStats retrieves project statistics.
func (s *StatsTools) ProjectStats(ctx context.Context, input ProjectStatsInput) (any, error) {
if input.ProjectID == "" {
return nil, WrapError("stats_project", ErrInvalidInput)
}
stats, _, err := s.client.ProjectStats(ctx, input.ProjectID)
if err != nil {
return nil, WrapError("stats_project", err)
}
return stats, nil
}
// SprintStatsInput represents input for eva_stats_sprint tool.
type SprintStatsInput struct {
SprintCode string `json:"sprint_code"`
}
// SprintStats retrieves sprint statistics.
func (s *StatsTools) SprintStats(ctx context.Context, input SprintStatsInput) (any, error) {
if input.SprintCode == "" {
return nil, WrapError("stats_sprint", ErrInvalidInput)
}
stats, err := s.client.SprintStats(ctx, input.SprintCode)
if err != nil {
return nil, WrapError("stats_sprint", err)
}
return stats, nil
}
// TimeSpentStatsInput represents input for eva_stats_timespent tool.
type TimeSpentStatsInput struct {
ProjectID string `json:"project_id"`
DateFrom string `json:"date_from,omitempty"`
DateTo string `json:"date_to,omitempty"`
}
// TimeSpentStats retrieves time spent report grouped by person and task.
func (s *StatsTools) TimeSpentStats(ctx context.Context, input TimeSpentStatsInput) (any, error) {
if input.ProjectID == "" {
return nil, WrapError("stats_timespent", ErrInvalidInput)
}
stats, err := s.client.TimeSpentStats(ctx, evateamclient.TimeSpentStatsParams{
ProjectID: input.ProjectID,
DateFrom: input.DateFrom,
DateTo: input.DateTo,
})
if err != nil {
return nil, WrapError("stats_timespent", err)
}
return stats, nil
}
// SprintExecutorsKPIInput represents input for eva_stats_sprint_executors_kpi tool.
type SprintExecutorsKPIInput struct {
ProjectCode string `json:"project_code,omitempty"`
SprintCode string `json:"sprint_code,omitempty"`
SprintStartDate time.Time `json:"sprint_start_date,omitempty"`
SprintEndDate time.Time `json:"sprint_end_date,omitempty"`
}
// SprintExecutorsKPI retrieves KPI report for closed sprint tasks grouped by executor.
// If sprint_code is empty, the report is aggregated across all project sprints.
func (s *StatsTools) SprintExecutorsKPI(ctx context.Context, input *SprintExecutorsKPIInput) (any, error) {
if input.ProjectCode == "" {
return nil, WrapError("stats_sprint_executors_kpi", ErrInvalidInput)
}
report, err := s.client.SprintExecutorsKPI(ctx, &evateamclient.SprintExecutorsKPIParams{
SprintCode: input.SprintCode,
ProjectCode: input.ProjectCode,
SprintStartDate: input.SprintStartDate,
SprintEndDate: input.SprintEndDate,
})
if err != nil {
return nil, WrapError("stats_sprint_executors_kpi", err)
}
return report, nil
}
/**
* This file is part of the raoptimus/evateamclient.go library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/evateamclient.go/blob/master/LICENSE.md
* @link https://github.com/raoptimus/evateamclient.go
*/
package tools
import (
"context"
sq "github.com/Masterminds/squirrel"
"github.com/raoptimus/evateamclient.go"
)
// StatusHistoryTools provides MCP tool handlers for status history operations.
type StatusHistoryTools struct {
client *evateamclient.Client
}
// NewStatusHistoryTools creates a new StatusHistoryTools instance.
func NewStatusHistoryTools(client *evateamclient.Client) *StatusHistoryTools {
return &StatusHistoryTools{client: client}
}
// StatusHistoryListInput represents input for eva_statushistory_list tool.
type StatusHistoryListInput struct {
QueryInput
TaskID string `json:"task_id,omitempty"`
ProjectID string `json:"project_id,omitempty"`
}
// StatusHistoryList returns a list of status history entries.
func (s *StatusHistoryTools) StatusHistoryList(ctx context.Context, input *StatusHistoryListInput) (*ListResult, error) {
qb, err := BuildQuery(evateamclient.EntityStatusHistory, &input.QueryInput)
if err != nil {
return nil, WrapError("statushistory_list", err)
}
if input.TaskID != "" {
qb = qb.Where(sq.Eq{"parent_id": input.TaskID})
}
if input.ProjectID != "" {
qb = qb.Where(sq.Eq{"project_id": input.ProjectID})
}
// Default order by creation time descending
if len(input.OrderBy) == 0 {
qb = qb.OrderBy("-cmf_created_at")
}
histories, _, err := s.client.StatusHistoryList(ctx, qb)
if err != nil {
return nil, WrapError("statushistory_list", err)
}
return &ListResult{
Items: toAnySlice(histories),
HasMore: len(histories) == input.Limit && input.Limit > 0,
}, nil
}
// StatusHistoryGetInput represents input for eva_statushistory_get tool.
type StatusHistoryGetInput struct {
ID string `json:"id"`
Fields []string `json:"fields,omitempty"`
}
// StatusHistoryGet retrieves a single status history entry.
func (s *StatusHistoryTools) StatusHistoryGet(ctx context.Context, input StatusHistoryGetInput) (any, error) {
if input.ID == "" {
return nil, WrapError("statushistory_get", ErrInvalidInput)
}
history, _, err := s.client.StatusHistory(ctx, input.ID, input.Fields)
if err != nil {
return nil, WrapError("statushistory_get", err)
}
return history, nil
}
// StatusHistoryCountInput represents input for eva_statushistory_count tool.
type StatusHistoryCountInput struct {
TaskID string `json:"task_id,omitempty"`
ProjectID string `json:"project_id,omitempty"`
}
// StatusHistoryCount counts status history entries.
func (s *StatusHistoryTools) StatusHistoryCount(ctx context.Context, input StatusHistoryCountInput) (*CountResult, error) {
qb := evateamclient.NewQueryBuilder().From(evateamclient.EntityStatusHistory)
if input.TaskID != "" {
qb = qb.Where(sq.Eq{"parent_id": input.TaskID})
}
if input.ProjectID != "" {
qb = qb.Where(sq.Eq{"project_id": input.ProjectID})
}
count, err := s.client.StatusHistoryCount(ctx, qb)
if err != nil {
return nil, WrapError("statushistory_count", err)
}
return &CountResult{Count: count}, nil
}
/**
* This file is part of the raoptimus/evateamclient.go library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/evateamclient.go/blob/master/LICENSE.md
* @link https://github.com/raoptimus/evateamclient.go
*/
package tools
import (
"context"
sq "github.com/Masterminds/squirrel"
"github.com/raoptimus/evateamclient.go"
)
// TagTools provides MCP tool handlers for tag operations.
type TagTools struct {
client *evateamclient.Client
}
// NewTagTools creates a new TagTools instance.
func NewTagTools(client *evateamclient.Client) *TagTools {
return &TagTools{client: client}
}
// TagListInput represents input for eva_tag_list tool.
type TagListInput struct {
QueryInput
// Optional filter by project ID or code (e.g. "epud").
ProjectID string `json:"project_id,omitempty"`
// Optional filter by tag name (exact match).
Name string `json:"name,omitempty"`
}
// TagList returns a list of tags matching filters.
func (t *TagTools) TagList(ctx context.Context, input *TagListInput) (*ListResult, error) {
qb, err := BuildQuery(evateamclient.EntityTag, &input.QueryInput)
if err != nil {
return nil, WrapError("tag_list", err)
}
if input.ProjectID != "" {
qb = qb.Where(sq.Eq{evateamclient.TagFieldProjectID: input.ProjectID})
}
if input.Name != "" {
qb = qb.Where(sq.Eq{evateamclient.TagFieldName: input.Name})
}
items, _, err := t.client.TagList(ctx, qb)
if err != nil {
return nil, WrapError("tag_list", err)
}
return &ListResult{
Items: toAnySlice(items),
HasMore: len(items) == input.Limit && input.Limit > 0,
}, nil
}
/**
* This file is part of the raoptimus/evateamclient.go library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/evateamclient.go/blob/master/LICENSE.md
* @link https://github.com/raoptimus/evateamclient.go
*/
package tools
import (
"context"
sq "github.com/Masterminds/squirrel"
"github.com/raoptimus/evateamclient.go"
)
// TaskTools provides MCP tool handlers for task operations.
type TaskTools struct {
client *evateamclient.Client
}
// NewTaskTools creates a new TaskTools instance.
func NewTaskTools(client *evateamclient.Client) *TaskTools {
return &TaskTools{client: client}
}
// TaskListInput represents input for eva_task_list tool.
type TaskListInput struct {
QueryInput
// Optional project filter
ProjectID string `json:"project_id,omitempty"`
// Optional status filter
StatusType string `json:"status_type,omitempty"`
// Optional sprint/list filter
SprintCode string `json:"sprint_code,omitempty"`
// Optional responsible person filter
ResponsibleID string `json:"responsible_id,omitempty"`
// Optional logic type ID filter (e.g., task type like "Target", "Epic", etc.)
LogicTypeID string `json:"logic_type_id,omitempty"`
}
// TaskList returns a list of tasks matching filters.
func (t *TaskTools) TaskList(ctx context.Context, input *TaskListInput) (*ListResult, error) {
// Build kwargs for complex filters
kwargs := BuildKwargs(&input.QueryInput)
// Add specific filters
var filters [][]any
if existingFilter, ok := kwargs["filter"].([][]any); ok {
filters = existingFilter
} else {
if singleFilter, ok := kwargs["filter"].([]any); ok {
filters = [][]any{singleFilter}
}
}
if input.ProjectID != "" {
filters = append(filters, []any{"project_id", "==", input.ProjectID})
}
if input.StatusType != "" {
filters = append(filters, []any{"cache_status_type", "==", input.StatusType})
}
if input.ResponsibleID != "" {
filters = append(filters, []any{"responsible_id", "==", input.ResponsibleID})
}
if input.LogicTypeID != "" {
filters = append(filters, []any{"logic_type_id", "==", input.LogicTypeID})
}
// Sprint uses "contains" operator
if input.SprintCode != "" {
filters = append(filters, []any{"lists", "contains", input.SprintCode})
}
if len(filters) == 1 {
kwargs["filter"] = filters[0]
} else if len(filters) > 1 {
kwargs["filter"] = filters
}
// Set default fields if not specified
if _, ok := kwargs["fields"]; !ok {
kwargs["fields"] = evateamclient.DefaultTaskListFields
}
tasks, _, err := t.client.Tasks(ctx, kwargs)
if err != nil {
return nil, WrapError("task_list", err)
}
return &ListResult{
Items: toAnySlice(tasks),
HasMore: len(tasks) == input.Limit && input.Limit > 0,
}, nil
}
// TaskGetInput represents input for eva_task_get tool.
type TaskGetInput struct {
// Task code (e.g., "PROJ-123")
Code string `json:"code,omitempty"`
// Task ID (e.g., "CmfTask:uuid")
ID string `json:"id,omitempty"`
// Fields to return
Fields []string `json:"fields,omitempty"`
}
// TaskGet retrieves a single task by code or ID.
func (t *TaskTools) TaskGet(ctx context.Context, input *TaskGetInput) (any, error) {
var qb *evateamclient.QueryBuilder
switch {
case input.Code != "":
qb = evateamclient.NewQueryBuilder().
Select(input.Fields...).
From(evateamclient.EntityTask).
Where(sq.Eq{"code": input.Code}).
Limit(1)
case input.ID != "":
qb = evateamclient.NewQueryBuilder().
Select(input.Fields...).
From(evateamclient.EntityTask).
Where(sq.Eq{"id": input.ID}).
Limit(1)
default:
return nil, WrapError("task_get", ErrInvalidInput)
}
task, _, err := t.client.TaskQuery(ctx, qb)
if err != nil {
return nil, WrapError("task_get", err)
}
return task, nil
}
// TaskCreateInput represents input for eva_task_create tool.
type TaskCreateInput struct {
Name string `json:"name"`
ProjectID string `json:"project_id"`
Text string `json:"text,omitempty"`
Priority int `json:"priority,omitempty"`
Deadline string `json:"deadline,omitempty"`
Responsible string `json:"responsible,omitempty"`
Executors []string `json:"executors,omitempty"`
Tags []string `json:"tags,omitempty"`
Lists []string `json:"lists,omitempty"`
Epic string `json:"epic,omitempty"`
ParentTask string `json:"parent_task,omitempty"`
LogicTypeID string `json:"logic_type_id,omitempty"`
}
// TaskCreate creates a new task.
func (t *TaskTools) TaskCreate(ctx context.Context, input *TaskCreateInput) (any, error) {
params := &evateamclient.TaskCreateParams{
Name: input.Name,
ProjectID: input.ProjectID,
Text: input.Text,
Priority: input.Priority,
Deadline: input.Deadline,
Responsible: input.Responsible,
Executors: input.Executors,
Tags: input.Tags,
Lists: input.Lists,
Epic: input.Epic,
ParentTask: input.ParentTask,
LogicTypeID: input.LogicTypeID,
}
task, err := t.client.TaskCreate(ctx, params)
if err != nil {
return nil, WrapError("task_create", err)
}
return task, nil
}
// TaskUpdateInput represents input for eva_task_update tool.
type TaskUpdateInput struct {
// Task ID (required)
ID string `json:"id"`
// Fields to update (any task field)
Updates map[string]any `json:"updates"`
// Tags to set on the task. Accepts tag codes (e.g. "TAG-000004").
// Replaces the existing tag list. Use eva_tag_list to find available tags.
Tags []string `json:"tags,omitempty"`
}
// TaskUpdate updates an existing task.
func (t *TaskTools) TaskUpdate(ctx context.Context, input TaskUpdateInput) (any, error) {
if input.ID == "" {
return nil, WrapError("task_update", ErrInvalidInput)
}
updates := input.Updates
if updates == nil {
updates = make(map[string]any)
}
if len(input.Tags) > 0 {
updates["tags"] = input.Tags
}
task, err := t.client.TaskUpdate(ctx, input.ID, updates)
if err != nil {
return nil, WrapError("task_update", err)
}
return task, nil
}
// TaskUpdateStatusInput represents input for eva_task_update_status tool.
type TaskUpdateStatusInput struct {
ID string `json:"id"`
Status string `json:"status"` // OPEN, IN_PROGRESS, CLOSED
}
// TaskUpdateStatus updates task status.
func (t *TaskTools) TaskUpdateStatus(ctx context.Context, input TaskUpdateStatusInput) (any, error) {
if input.ID == "" || input.Status == "" {
return nil, WrapError("task_update_status", ErrInvalidInput)
}
task, err := t.client.TaskUpdateStatus(ctx, input.ID, input.Status)
if err != nil {
return nil, WrapError("task_update_status", err)
}
return task, nil
}
// TaskDeleteInput represents input for eva_task_delete tool.
type TaskDeleteInput struct {
ID string `json:"id"`
}
// TaskDelete deletes a task.
func (t *TaskTools) TaskDelete(ctx context.Context, input TaskDeleteInput) (any, error) {
if input.ID == "" {
return nil, WrapError("task_delete", ErrInvalidInput)
}
err := t.client.TaskDelete(ctx, input.ID)
if err != nil {
return nil, WrapError("task_delete", err)
}
return map[string]bool{"success": true}, nil
}
// TaskArchiveInput represents input for eva_task_archive tool.
type TaskArchiveInput struct {
ID string `json:"id"`
}
// TaskArchive archives a task (soft delete).
func (t *TaskTools) TaskArchive(ctx context.Context, input TaskArchiveInput) (any, error) {
if input.ID == "" {
return nil, WrapError("task_archive", ErrInvalidInput)
}
err := t.client.TaskArchive(ctx, input.ID)
if err != nil {
return nil, WrapError("task_archive", err)
}
return map[string]bool{"success": true}, nil
}
// TaskCountInput represents input for eva_task_count tool.
type TaskCountInput struct {
ProjectID string `json:"project_id,omitempty"`
StatusType string `json:"status_type,omitempty"`
SprintCode string `json:"sprint_code,omitempty"`
ResponsibleID string `json:"responsible_id,omitempty"`
}
// TaskCount counts tasks matching filters.
func (t *TaskTools) TaskCount(ctx context.Context, input TaskCountInput) (*CountResult, error) {
kwargs := make(map[string]any)
var filters [][]any
if input.ProjectID != "" {
filters = append(filters, []any{"project_id", "==", input.ProjectID})
}
if input.StatusType != "" {
filters = append(filters, []any{"cache_status_type", "==", input.StatusType})
}
if input.ResponsibleID != "" {
filters = append(filters, []any{"responsible_id", "==", input.ResponsibleID})
}
if input.SprintCode != "" {
filters = append(filters, []any{"lists", "contains", input.SprintCode})
}
if len(filters) == 1 {
kwargs["filter"] = filters[0]
} else if len(filters) > 1 {
kwargs["filter"] = filters
}
count, _, err := t.client.TasksCount(ctx, kwargs)
if err != nil {
return nil, WrapError("task_count", err)
}
return &CountResult{Count: int(count)}, nil
}
/**
* This file is part of the raoptimus/evateamclient.go library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/evateamclient.go/blob/master/LICENSE.md
* @link https://github.com/raoptimus/evateamclient.go
*/
package tools
import (
"context"
"github.com/raoptimus/evateamclient.go"
)
// TaskLinkTools provides MCP tool handlers for task link operations.
type TaskLinkTools struct {
client *evateamclient.Client
}
// NewTaskLinkTools creates a new TaskLinkTools instance.
func NewTaskLinkTools(client *evateamclient.Client) *TaskLinkTools {
return &TaskLinkTools{client: client}
}
// TaskLinkListInput represents input for eva_tasklink_list tool.
type TaskLinkListInput struct {
QueryInput
TaskID string `json:"task_id,omitempty"`
Direction string `json:"direction,omitempty"` // "outgoing", "incoming", "both" (default)
}
// TaskLinkList returns a list of task links.
func (t *TaskLinkTools) TaskLinkList(ctx context.Context, input *TaskLinkListInput) (*ListResult, error) {
if input.TaskID == "" {
// List all links with custom query
qb, err := BuildQuery(evateamclient.EntityRelation, &input.QueryInput)
if err != nil {
return nil, WrapError("tasklink_list", err)
}
links, _, err := t.client.TaskLinksListQuery(ctx, qb)
if err != nil {
return nil, WrapError("tasklink_list", err)
}
return &ListResult{
Items: toAnySlice(links),
HasMore: len(links) == input.Limit && input.Limit > 0,
}, nil
}
// List links for specific task
var (
links []any
err error
)
switch input.Direction {
case "outgoing":
result, _, e := t.client.TaskLinksOutgoing(ctx, input.TaskID, nil)
links, err = toAnySlice(result), e
case "incoming":
result, _, e := t.client.TaskLinksIncoming(ctx, input.TaskID, nil)
links, err = toAnySlice(result), e
default: // "both" or empty
result, _, e := t.client.TaskLinks(ctx, input.TaskID, nil)
links, err = toAnySlice(result), e
}
if err != nil {
return nil, WrapError("tasklink_list", err)
}
return &ListResult{
Items: links,
HasMore: false,
}, nil
}
// TaskLinkGetInput represents input for eva_tasklink_get tool.
type TaskLinkGetInput struct {
ID string `json:"id"`
Fields []string `json:"fields,omitempty"`
}
// TaskLinkGet retrieves a single task link.
func (t *TaskLinkTools) TaskLinkGet(ctx context.Context, input TaskLinkGetInput) (any, error) {
if input.ID == "" {
return nil, WrapError("tasklink_get", ErrInvalidInput)
}
link, _, err := t.client.TaskLink(ctx, input.ID, input.Fields)
if err != nil {
return nil, WrapError("tasklink_get", err)
}
return link, nil
}
// TaskLinkCreateInput represents input for eva_tasklink_create tool.
type TaskLinkCreateInput struct {
SourceTaskID string `json:"source_task_id"`
TargetTaskID string `json:"target_task_id"`
RelationOptionID string `json:"relation_option_id"`
}
// TaskLinkCreate creates a new task link.
func (t *TaskLinkTools) TaskLinkCreate(ctx context.Context, input TaskLinkCreateInput) (any, error) {
if input.SourceTaskID == "" || input.TargetTaskID == "" || input.RelationOptionID == "" {
return nil, WrapError("tasklink_create", ErrInvalidInput)
}
link, err := t.client.TaskLinkCreate(ctx, input.SourceTaskID, input.TargetTaskID, input.RelationOptionID)
if err != nil {
return nil, WrapError("tasklink_create", err)
}
return link, nil
}
// TaskLinkDeleteInput represents input for eva_tasklink_delete tool.
type TaskLinkDeleteInput struct {
ID string `json:"id"`
}
// TaskLinkDelete deletes a task link.
func (t *TaskLinkTools) TaskLinkDelete(ctx context.Context, input TaskLinkDeleteInput) (any, error) {
if input.ID == "" {
return nil, WrapError("tasklink_delete", ErrInvalidInput)
}
err := t.client.TaskLinkDelete(ctx, input.ID)
if err != nil {
return nil, WrapError("tasklink_delete", err)
}
return map[string]bool{"success": true}, nil
}
// TaskLinkCountInput represents input for eva_tasklink_count tool.
type TaskLinkCountInput struct {
TaskID string `json:"task_id,omitempty"`
Direction string `json:"direction,omitempty"` // "outgoing", "incoming", "both"
}
// TaskLinkCount counts task links.
func (t *TaskLinkTools) TaskLinkCount(ctx context.Context, input TaskLinkCountInput) (*CountResult, error) {
if input.TaskID == "" {
qb := evateamclient.NewQueryBuilder().From(evateamclient.EntityRelation)
count, err := t.client.TaskLinkCount(ctx, qb)
if err != nil {
return nil, WrapError("tasklink_count", err)
}
return &CountResult{Count: count}, nil
}
// Count links for specific task - need to query and count
links, _, err := t.client.TaskLinks(ctx, input.TaskID, []string{"id"})
if err != nil {
return nil, WrapError("tasklink_count", err)
}
return &CountResult{Count: len(links)}, nil
}
/**
* This file is part of the raoptimus/evateamclient.go library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/evateamclient.go/blob/master/LICENSE.md
* @link https://github.com/raoptimus/evateamclient.go
*/
package tools
import (
"context"
sq "github.com/Masterminds/squirrel"
"github.com/raoptimus/evateamclient.go"
)
// TimeLogTools provides MCP tool handlers for time log operations.
type TimeLogTools struct {
client *evateamclient.Client
}
// NewTimeLogTools creates a new TimeLogTools instance.
func NewTimeLogTools(client *evateamclient.Client) *TimeLogTools {
return &TimeLogTools{client: client}
}
// TimeLogListInput represents input for eva_timelog_list tool.
type TimeLogListInput struct {
QueryInput
TaskID string `json:"task_id,omitempty"`
UserID string `json:"user_id,omitempty"`
ProjectID string `json:"project_id,omitempty"`
}
// TimeLogList returns a list of time log entries.
func (t *TimeLogTools) TimeLogList(ctx context.Context, input *TimeLogListInput) (*ListResult, error) {
// For project filter, use kwargs (dot notation for nested field)
if input.ProjectID != "" {
kwargs := BuildKwargs(&input.QueryInput)
kwargs["filter"] = []any{"parent.project_id", "==", input.ProjectID}
logs, _, err := t.client.TimeLogs(ctx, kwargs)
if err != nil {
return nil, WrapError("timelog_list", err)
}
return &ListResult{
Items: toAnySlice(logs),
HasMore: len(logs) == input.Limit && input.Limit > 0,
}, nil
}
qb, err := BuildQuery(evateamclient.EntityTimeLog, &input.QueryInput)
if err != nil {
return nil, WrapError("timelog_list", err)
}
if input.TaskID != "" {
qb = qb.Where(sq.Eq{"parent_id": input.TaskID})
}
if input.UserID != "" {
qb = qb.Where(sq.Eq{"cmf_owner_id": input.UserID})
}
logs, _, err := t.client.TimeLogsList(ctx, qb)
if err != nil {
return nil, WrapError("timelog_list", err)
}
return &ListResult{
Items: toAnySlice(logs),
HasMore: len(logs) == input.Limit && input.Limit > 0,
}, nil
}
// TimeLogGetInput represents input for eva_timelog_get tool.
type TimeLogGetInput struct {
ID string `json:"id"`
Fields []string `json:"fields,omitempty"`
}
// TimeLogGet retrieves a single time log entry.
func (t *TimeLogTools) TimeLogGet(ctx context.Context, input TimeLogGetInput) (any, error) {
if input.ID == "" {
return nil, WrapError("timelog_get", ErrInvalidInput)
}
log, _, err := t.client.TimeLog(ctx, input.ID, input.Fields)
if err != nil {
return nil, WrapError("timelog_get", err)
}
return log, nil
}
// TimeLogCreateInput represents input for eva_timelog_create tool.
type TimeLogCreateInput struct {
TaskID string `json:"task_id"`
TimeSpent int `json:"time_spent"` // minutes
}
// TimeLogCreate creates a new time log entry.
func (t *TimeLogTools) TimeLogCreate(ctx context.Context, input TimeLogCreateInput) (any, error) {
if input.TaskID == "" || input.TimeSpent <= 0 {
return nil, WrapError("timelog_create", ErrInvalidInput)
}
params := evateamclient.TimeLogCreateParams{
ParentID: input.TaskID,
TimeSpent: input.TimeSpent,
}
log, err := t.client.TimeLogCreate(ctx, params)
if err != nil {
return nil, WrapError("timelog_create", err)
}
return log, nil
}
// TimeLogUpdateInput represents input for eva_timelog_update tool.
type TimeLogUpdateInput struct {
ID string `json:"id"`
Updates map[string]any `json:"updates"`
}
// TimeLogUpdate updates an existing time log entry.
func (t *TimeLogTools) TimeLogUpdate(ctx context.Context, input TimeLogUpdateInput) (any, error) {
if input.ID == "" {
return nil, WrapError("timelog_update", ErrInvalidInput)
}
log, err := t.client.TimeLogUpdate(ctx, input.ID, input.Updates)
if err != nil {
return nil, WrapError("timelog_update", err)
}
return log, nil
}
// TimeLogDeleteInput represents input for eva_timelog_delete tool.
type TimeLogDeleteInput struct {
ID string `json:"id"`
}
// TimeLogDelete deletes a time log entry.
func (t *TimeLogTools) TimeLogDelete(ctx context.Context, input TimeLogDeleteInput) (any, error) {
if input.ID == "" {
return nil, WrapError("timelog_delete", ErrInvalidInput)
}
err := t.client.TimeLogDelete(ctx, input.ID)
if err != nil {
return nil, WrapError("timelog_delete", err)
}
return map[string]bool{"success": true}, nil
}
// TimeLogCountInput represents input for eva_timelog_count tool.
type TimeLogCountInput struct {
TaskID string `json:"task_id,omitempty"`
UserID string `json:"user_id,omitempty"`
}
// TimeLogCount counts time log entries.
func (t *TimeLogTools) TimeLogCount(ctx context.Context, input TimeLogCountInput) (*CountResult, error) {
qb := evateamclient.NewQueryBuilder().From(evateamclient.EntityTimeLog)
if input.TaskID != "" {
qb = qb.Where(sq.Eq{"parent_id": input.TaskID})
}
if input.UserID != "" {
qb = qb.Where(sq.Eq{"cmf_owner_id": input.UserID})
}
count, err := t.client.TimeLogCount(ctx, qb)
if err != nil {
return nil, WrapError("timelog_count", err)
}
return &CountResult{Count: count}, nil
}
/**
* This file is part of the raoptimus/evateamclient.go library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/evateamclient.go/blob/master/LICENSE.md
* @link https://github.com/raoptimus/evateamclient.go
*/
package evateamclient
import (
"context"
sq "github.com/Masterminds/squirrel"
"github.com/pkg/errors"
"github.com/raoptimus/evateamclient.go/models"
)
// Project field constants for type-safe queries
const (
ProjectFieldID = "id"
ProjectFieldClassName = "class_name"
ProjectFieldCode = "code"
ProjectFieldName = "name"
ProjectFieldText = "text"
ProjectFieldCMFLockedAt = "cmf_locked_at"
ProjectFieldCMFCreatedAt = "cmf_created_at"
ProjectFieldCMFModifiedAt = "cmf_modified_at"
ProjectFieldCMFViewedAt = "cmf_viewed_at"
ProjectFieldCMFDeleted = "cmf_deleted"
ProjectFieldCMFVersion = "cmf_version"
ProjectFieldCacheStatusType = "cache_status_type"
ProjectFieldWorkflowType = "workflow_type"
ProjectFieldWorkflowID = "workflow_id"
ProjectFieldParentID = "parent_id"
ProjectFieldCmfOwnerID = "cmf_owner_id"
ProjectFieldSystem = "system"
ProjectFieldImportOriginal = "import_original"
ProjectFieldSlOwnerLock = "sl_owner_lock"
ProjectFieldPermParentOwnerID = "perm_parent_owner_id"
ProjectFieldPermInheritACLID = "perm_inherit_acl_id"
ProjectFieldPermEffectiveACLID = "perm_effective_acl_id"
ProjectFieldPermSecurityLevelCache = "perm_security_level_allowed_ids_cache"
ProjectFieldIsTemplate = "is_template"
ProjectFieldExecutors = "executors"
ProjectFieldAdmins = "cmfprojectadmins"
ProjectFieldSpectators = "spectators"
ProjectFieldOwnerAssistants = "cmf_owner_assistants"
)
var (
// DefaultProjectFields - standard projection for single project queries
DefaultProjectFields = []string{
ProjectFieldID,
ProjectFieldName,
ProjectFieldCode,
ProjectFieldCMFCreatedAt,
ProjectFieldCMFModifiedAt,
ProjectFieldExecutors,
ProjectFieldAdmins,
ProjectFieldSpectators,
ProjectFieldOwnerAssistants,
}
// DefaultProjectListFields - optimized for LIST queries (lighter payload)
DefaultProjectListFields = []string{
ProjectFieldID,
ProjectFieldClassName,
ProjectFieldCode,
ProjectFieldName,
ProjectFieldCacheStatusType,
ProjectFieldCmfOwnerID,
ProjectFieldWorkflowID,
ProjectFieldSystem,
ProjectFieldSlOwnerLock,
}
)
// Project retrieves a single project by code (backward compatible)
// Example:
//
// project, meta, err := client.Project(ctx, "PROJ-123", nil)
func (c *Client) Project(
ctx context.Context,
code string,
fields []string,
) (*models.Project, *models.Meta, error) {
// Use real Squirrel builder
qb := NewQueryBuilder().
Select(fields...).
From(EntityProject).
Where(sq.Eq{ProjectFieldCode: code}).
Limit(1)
return c.ProjectQuery(ctx, qb)
}
// ProjectQuery executes query using REAL Squirrel API
// Example:
//
// qb := evateamclient.NewQueryBuilder().
// Select("id", "name", "executors").
// From(evateamclient.EntityProject).
// Where(sq.Eq{"code": "PROJ-123"})
// project, meta, err := client.ProjectQuery(ctx, qb)
func (c *Client) ProjectQuery(
ctx context.Context,
qb *QueryBuilder,
) (*models.Project, *models.Meta, error) {
kwargs, err := qb.ToKwargs()
if err != nil {
return nil, nil, err
}
// Apply default fields if none specified
if _, hasFields := kwargs["fields"]; !hasFields {
kwargs["fields"] = DefaultProjectFields
}
method, err := qb.ToMethod(true)
if err != nil {
return nil, nil, err
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: method,
CallID: newCallID(),
Kwargs: kwargs,
}
var resp models.ProjectGetResponse
if err := c.doRequest(ctx, reqBody, &resp); err != nil {
return nil, nil, errors.WithMessage(err, "failed to get project")
}
return &resp.Result, &resp.Meta, nil
}
// ProjectsList retrieves list using REAL Squirrel
// Example:
//
// qb := evateamclient.NewQueryBuilder().
// Select("id", "code", "name").
// From(evateamclient.EntityProject).
// Where(sq.Like{"name": "%Mobile%"}).
// Where(sq.Eq{"system": false}).
// OrderBy("-cmf_created_at").
// Offset(0).Limit(50)
// projects, meta, err := client.ProjectsList(ctx, qb)
func (c *Client) ProjectsList(
ctx context.Context,
qb *QueryBuilder,
) ([]models.Project, *models.Meta, error) {
kwargs, err := qb.ToKwargs()
if err != nil {
return nil, nil, err
}
// Apply default fields if none specified
if _, hasFields := kwargs["fields"]; !hasFields {
kwargs["fields"] = DefaultProjectListFields
}
method, err := qb.ToMethod(false)
if err != nil {
return nil, nil, err
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: method,
CallID: newCallID(),
Kwargs: kwargs,
}
var resp models.ProjectListResponse
if err := c.doRequest(ctx, reqBody, &resp); err != nil {
return nil, nil, errors.WithMessage(err, "failed to get projects")
}
return resp.Result, &resp.Meta, nil
}
// ProjectCount counts using REAL Squirrel
// Example:
//
// qb := evateamclient.NewQueryBuilder().
// From(evateamclient.EntityProject).
// Where(sq.Eq{"system": false})
// count, err := client.ProjectCount(ctx, qb)
func (c *Client) ProjectCount(
ctx context.Context,
qb *QueryBuilder,
) (int, error) {
kwargs, err := qb.ToKwargs()
if err != nil {
return 0, err
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: "CmfProject.count",
CallID: newCallID(),
Kwargs: kwargs,
}
var resp struct {
JSONRPC string `json:"jsonrpc"`
Result int `json:"result"`
}
if err := c.doRequest(ctx, reqBody, &resp); err != nil {
return 0, errors.WithMessage(err, "failed to get project count")
}
return resp.Result, nil
}
// Backward compatible methods (using old API)
// Projects retrieves a list of projects (backward compatible, deprecated)
// Recommended: use ProjectsSquirrel with NewEvaBuilder() instead
func (c *Client) Projects(
ctx context.Context,
fields []string,
kwargs map[string]any,
) ([]models.Project, *models.Meta, error) {
if len(kwargs) == 0 {
kwargs = make(map[string]any)
}
if len(fields) == 0 {
fields = DefaultProjectListFields
}
kwargs["fields"] = fields
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: "CmfProject.list",
CallID: newCallID(),
Kwargs: kwargs,
}
var resp models.ProjectListResponse
if err := c.doRequest(ctx, reqBody, &resp); err != nil {
return nil, nil, errors.WithMessage(err, "failed to get projects")
}
return resp.Result, &resp.Meta, nil
}
// CRUD Operations
// ProjectCreateParams contains parameters for creating a new project
type ProjectCreateParams struct {
Code string `json:"code"`
Name string `json:"name"`
Text string `json:"text,omitempty"`
WorkflowID string `json:"workflow_id,omitempty"`
Executors []string `json:"executors,omitempty"`
Admins []string `json:"cmfprojectadmins,omitempty"`
}
// ProjectCreate creates a new project
// Example:
//
// params := evateamclient.ProjectCreateParams{
// Code: "NEWPROJ",
// Name: "New Project",
// }
// project, err := client.ProjectCreate(ctx, params)
func (c *Client) ProjectCreate(
ctx context.Context,
params *ProjectCreateParams,
) (*models.Project, error) {
kwargs := map[string]any{
"code": params.Code,
"name": params.Name,
}
if params.Text != "" {
kwargs["text"] = params.Text
}
if params.WorkflowID != "" {
kwargs["workflow_id"] = params.WorkflowID
}
if len(params.Executors) > 0 {
kwargs["executors"] = params.Executors
}
if len(params.Admins) > 0 {
kwargs["cmfprojectadmins"] = params.Admins
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: "CmfProject.create",
CallID: newCallID(),
Kwargs: kwargs,
}
var resp models.ProjectGetResponse
if err := c.doRequest(ctx, reqBody, &resp); err != nil {
return nil, err
}
return &resp.Result, nil
}
// ProjectUpdate updates an existing project
// Example:
//
// updates := map[string]any{
// "name": "Updated Project Name",
// }
// project, err := client.ProjectUpdate(ctx, "Project:uuid", updates)
func (c *Client) ProjectUpdate(
ctx context.Context,
projectID string,
updates map[string]any,
) (*models.Project, error) {
kwargs := map[string]any{
"id": projectID,
}
for k, v := range updates {
kwargs[k] = v
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: "CmfProject.update",
CallID: newCallID(),
Kwargs: kwargs,
}
var resp models.ProjectGetResponse
if err := c.doRequest(ctx, reqBody, &resp); err != nil {
return nil, err
}
return &resp.Result, nil
}
// ProjectDelete deletes a project by ID
// Example:
//
// err := client.ProjectDelete(ctx, "Project:uuid")
func (c *Client) ProjectDelete(
ctx context.Context,
projectID string,
) error {
kwargs := map[string]any{
"id": projectID,
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: "CmfProject.delete",
CallID: newCallID(),
Kwargs: kwargs,
}
var resp struct {
JSONRPC string `json:"jsonrpc"`
Result bool `json:"result"`
}
return c.doRequest(ctx, reqBody, &resp)
}
// ProjectAddExecutor adds an executor to project
// Example:
//
// err := client.ProjectAddExecutor(ctx, "Project:uuid", "Person:uuid")
func (c *Client) ProjectAddExecutor(
ctx context.Context,
projectID, personID string,
) error {
kwargs := map[string]any{
"id": projectID,
"executors": []string{personID},
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: "CmfProject.add_executors",
CallID: newCallID(),
Kwargs: kwargs,
}
var resp struct {
JSONRPC string `json:"jsonrpc"`
Result bool `json:"result"`
}
return c.doRequest(ctx, reqBody, &resp)
}
// ProjectRemoveExecutor removes an executor from project
// Example:
//
// err := client.ProjectRemoveExecutor(ctx, "Project:uuid", "Person:uuid")
func (c *Client) ProjectRemoveExecutor(
ctx context.Context,
projectID, personID string,
) error {
kwargs := map[string]any{
"id": projectID,
"executors": []string{personID},
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: "CmfProject.remove_executors",
CallID: newCallID(),
Kwargs: kwargs,
}
var resp struct {
JSONRPC string `json:"jsonrpc"`
Result bool `json:"result"`
}
return c.doRequest(ctx, reqBody, &resp)
}
/**
* This file is part of the raoptimus/evateamclient.go library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/evateamclient.go/blob/master/LICENSE.md
* @link https://github.com/raoptimus/evateamclient.go
*/
package evateamclient
import (
"fmt"
"strings"
sq "github.com/Masterminds/squirrel"
)
// QueryBuilder wraps Squirrel's SelectBuilder and converts to EVA API kwargs
// This allows using real Squirrel API with EVA Team backend
//
// Example:
//
// qb := evateamclient.NewQueryBuilder().
// Select("id", "name", "code").
// From(EntityProject).
// Where(sq.Eq{"code": "PROJ-123"}).
// OrderBy("-cmf_created_at").
// Limit(50)
//
// projects, meta, err := client.ProjectsList(ctx, qb)
type QueryBuilder struct {
selectBuilder sq.SelectBuilder
includeArch bool
noMeta bool
}
// NewQueryBuilder creates a new EVA-compatible Squirrel builder
func NewQueryBuilder() *QueryBuilder {
return &QueryBuilder{
selectBuilder: sq.Select(),
noMeta: true,
}
}
// Select sets columns to retrieve (maps to EVA "fields")
// If no columns provided, default fields will be applied by the caller.
// Example: qb.Select("id", "name", "code", "executors")
func (qb *QueryBuilder) Select(columns ...string) *QueryBuilder {
if len(columns) > 0 {
qb.selectBuilder = qb.selectBuilder.Columns(columns...)
}
return qb
}
// From sets the entity type (required for EVA API method routing)
// Valid values: EntityProject, EntityTask, EntityDocument, etc.
// Example: qb.From(EntityProject)
func (qb *QueryBuilder) From(table string) *QueryBuilder {
qb.selectBuilder = qb.selectBuilder.From(table)
return qb
}
// Where adds filter conditions using Squirrel predicates
// Multiple Where() calls are combined with AND logic
//
// Examples:
//
// qb.Where(sq.Eq{"code": "PROJ-123"})
// qb.Where(sq.Gt{"priority": 3})
// qb.Where(sq.Like{"name": "%Mobile%"})
// qb.Where(sq.And{sq.Eq{"system": false}, sq.GtOrEq{"created_at": "2024-01-01"}})
func (qb *QueryBuilder) Where(pred any) *QueryBuilder {
qb.selectBuilder = qb.selectBuilder.Where(pred)
return qb
}
// OrderBy adds sorting
// Use "-field" prefix for DESC order, "field" for ASC
//
// Examples:
//
// qb.OrderBy("name") // ASC
// qb.OrderBy("-cmf_created_at") // DESC
// qb.OrderBy("-priority", "name") // Multiple columns
func (qb *QueryBuilder) OrderBy(orderBys ...string) *QueryBuilder {
qb.selectBuilder = qb.selectBuilder.OrderBy(orderBys...)
return qb
}
// Limit sets maximum number of results
// Example: qb.Limit(50)
func (qb *QueryBuilder) Limit(limit uint64) *QueryBuilder {
qb.selectBuilder = qb.selectBuilder.Limit(limit)
return qb
}
// Offset sets result offset for pagination
// Example: qb.Offset(100).Limit(50) // Skip 100, take 50
func (qb *QueryBuilder) Offset(offset uint64) *QueryBuilder {
qb.selectBuilder = qb.selectBuilder.Offset(offset)
return qb
}
// IncludeArchived includes deleted/archived objects (EVA-specific)
// Example: qb.Where(sq.Eq{"cmf_deleted": true}).IncludeArchived()
func (qb *QueryBuilder) IncludeArchived() *QueryBuilder {
qb.includeArch = true
return qb
}
// NoMeta disables meta response (EVA-specific, faster queries)
// Example: qb.NoMeta() // Skip metadata for better performance
func (qb *QueryBuilder) NoMeta() *QueryBuilder {
qb.noMeta = true
return qb
}
// ToKwargs converts Squirrel SelectBuilder to EVA API kwargs
// This translates SQL-like queries to JSON-RPC BQL format
//
// Returns map with keys: filter, fields, order_by, slice, include_archived, no_meta
func (qb *QueryBuilder) ToKwargs() (map[string]any, error) {
kwargs := make(map[string]any)
// Extract parts from Squirrel builder
sqlStr, args, err := qb.safeBuilder().ToSql()
if err != nil {
return nil, fmt.Errorf("squirrel.ToSql: %w", err)
}
// Parse SQL to extract EVA components
parts, err := parseSquirrelSQL(sqlStr, args)
if err != nil {
return nil, err
}
// Convert WHERE clause to EVA filter
if len(parts.filters) > 0 {
if len(parts.filters) == 1 {
kwargs["filter"] = parts.filters[0]
} else {
kwargs["filter"] = parts.filters
}
}
// Convert SELECT columns to EVA fields
if len(parts.fields) > 0 {
kwargs["fields"] = parts.fields
}
// Convert ORDER BY to EVA order_by
if len(parts.orderBy) > 0 {
kwargs["order_by"] = parts.orderBy
}
// Convert LIMIT/OFFSET to EVA slice
// EVA API uses Python-like slice: [start, end] where end is the index, not count
if parts.limit > 0 || parts.offset > 0 {
kwargs["slice"] = []uint64{parts.offset, parts.offset + parts.limit}
}
// EVA-specific flags
if qb.includeArch {
kwargs["include_archived"] = true
}
if qb.noMeta {
kwargs["no_meta"] = true
}
return kwargs, nil
}
// ToMethod returns the appropriate EVA API method based on table
// Example: "CmfProject" -> "CmfProject.list" or "CmfProject.get" if single is true
func (qb *QueryBuilder) ToMethod(single bool) (string, error) {
// Extract table name from Squirrel builder
sqlStr, _, err := qb.safeBuilder().ToSql()
if err != nil {
return "", err
}
table := extractTableName(sqlStr)
if table == "" {
return "", fmt.Errorf("table name not found in query, use From()")
}
if single {
return table + ".get", nil
}
// Determine method based on query type
// If has LIMIT 1 or specific ID filter, use .get, otherwise .list
return table + ".list", nil
}
// Validate checks if query is valid before execution
// Returns error if query has invalid parameters
// safeBuilder returns selectBuilder with at least one column.
// Squirrel requires at least one result column for ToSql();
// if no columns were specified, "*" is used as a placeholder.
func (qb *QueryBuilder) safeBuilder() sq.SelectBuilder {
if _, _, err := qb.selectBuilder.ToSql(); err != nil {
return qb.selectBuilder.Columns("*")
}
return qb.selectBuilder
}
func (qb *QueryBuilder) Validate() error {
// Check if From() was called
sqlStr, _, err := qb.safeBuilder().ToSql()
if err != nil {
return fmt.Errorf("invalid query: %w", err)
}
if !strings.Contains(sqlStr, " FROM ") {
return fmt.Errorf("missing From() clause - specify entity type")
}
return nil
}
// String returns human-readable query representation (for debugging)
func (qb *QueryBuilder) String() string {
sqlStr, args, err := qb.selectBuilder.ToSql()
if err != nil {
return fmt.Sprintf("QueryBuilder{err=%v}", err)
}
return fmt.Sprintf("QueryBuilder{sql=%q, args=%v, includeArch=%v, noMeta=%v}",
sqlStr, args, qb.includeArch, qb.noMeta)
}
// sqlParts holds parsed SQL components
type sqlParts struct {
fields []string
table string
filters []any
orderBy []string
limit uint64
offset uint64
}
// parseSquirrelSQL converts Squirrel SQL to EVA BQL components
// This is a simplified parser - production version needs more robust parsing
func parseSquirrelSQL(sqlStr string, args []any) (*sqlParts, error) {
parts := &sqlParts{
fields: []string{},
filters: []any{},
orderBy: []string{},
}
// Extract SELECT columns
if idx := strings.Index(sqlStr, "SELECT "); idx >= 0 {
fromIdx := strings.Index(sqlStr, " FROM ")
if fromIdx > 0 {
colsStr := strings.TrimSpace(sqlStr[idx+7 : fromIdx])
if colsStr != "*" {
parts.fields = strings.Split(colsStr, ", ")
}
}
}
// Extract table name
parts.table = extractTableName(sqlStr)
// Extract WHERE conditions
if whereIdx := strings.Index(sqlStr, "WHERE "); whereIdx >= 0 {
parts.filters = convertSquirrelFilters(sqlStr[whereIdx:], args)
}
// Extract ORDER BY
if orderIdx := strings.Index(sqlStr, "ORDER BY "); orderIdx >= 0 {
limitIdx := strings.Index(sqlStr[orderIdx:], " LIMIT ")
endIdx := len(sqlStr)
if limitIdx > 0 {
endIdx = orderIdx + limitIdx
}
orderStr := strings.TrimSpace(sqlStr[orderIdx+9 : endIdx])
parts.orderBy = parseOrderBy(orderStr)
}
// Extract LIMIT/OFFSET
if limitIdx := strings.Index(sqlStr, "LIMIT "); limitIdx >= 0 {
if _, err := fmt.Sscanf(sqlStr[limitIdx:], "LIMIT %d", &parts.limit); err != nil {
return nil, err
}
}
if offsetIdx := strings.Index(sqlStr, "OFFSET "); offsetIdx >= 0 {
if _, err := fmt.Sscanf(sqlStr[offsetIdx:], "OFFSET %d", &parts.offset); err != nil {
return nil, err
}
}
return parts, nil
}
// extractTableName extracts table name from SQL string
func extractTableName(sqlStr string) string {
fromIdx := strings.Index(sqlStr, " FROM ")
if fromIdx < 0 {
return ""
}
afterFrom := sqlStr[fromIdx+6:]
whereIdx := strings.Index(afterFrom, " WHERE ")
orderIdx := strings.Index(afterFrom, " ORDER BY ")
limitIdx := strings.Index(afterFrom, " LIMIT ")
endIdx := len(afterFrom)
if whereIdx > 0 && whereIdx < endIdx {
endIdx = whereIdx
}
if orderIdx > 0 && orderIdx < endIdx {
endIdx = orderIdx
}
if limitIdx > 0 && limitIdx < endIdx {
endIdx = limitIdx
}
return strings.TrimSpace(afterFrom[:endIdx])
}
// convertSquirrelFilters converts SQL WHERE to EVA BQL filters
// Handles Squirrel's Eq, Gt, Lt, Like, etc.
func convertSquirrelFilters(whereClause string, args []any) []any {
var filters []any
whereClause = strings.TrimSpace(whereClause)
whereClause = strings.TrimPrefix(whereClause, "WHERE ")
// Simple parser for common patterns, preserving argument order.
conditions := splitTopLevelAND(whereClause)
argIdx := 0
for _, cond := range conditions {
cond = trimWrappingParens(cond)
if cond == "" {
continue
}
upperCond := strings.ToUpper(cond)
// Pattern: field IN (?, ?, ...)
if inIdx := strings.Index(upperCond, " IN ("); inIdx >= 0 {
fieldName := extractLastWord(cond[:inIdx])
openIdx := strings.Index(cond[inIdx:], "(")
closeIdx := strings.Index(cond[inIdx:], ")")
if openIdx >= 0 && closeIdx > openIdx {
inPart := cond[inIdx+openIdx : inIdx+closeIdx+1]
placeholders := strings.Count(inPart, "?")
values := make([]any, 0, placeholders)
for i := 0; i < placeholders && argIdx < len(args); i++ {
values = append(values, args[argIdx])
argIdx++
}
if len(values) > 0 {
filters = append(filters, []any{fieldName, "IN", values})
}
}
continue
}
switch {
case strings.Contains(cond, " >= ?"):
fieldName := extractLastWord(strings.Split(cond, " >= ?")[0])
if argIdx < len(args) {
filters = append(filters, []any{fieldName, ">=", args[argIdx]})
argIdx++
}
case strings.Contains(cond, " <= ?"):
fieldName := extractLastWord(strings.Split(cond, " <= ?")[0])
if argIdx < len(args) {
filters = append(filters, []any{fieldName, "<=", args[argIdx]})
argIdx++
}
case strings.Contains(cond, " != ?"):
fieldName := extractLastWord(strings.Split(cond, " != ?")[0])
if argIdx < len(args) {
filters = append(filters, []any{fieldName, "!=", args[argIdx]})
argIdx++
}
case strings.Contains(cond, " = ?"):
fieldName := extractLastWord(strings.Split(cond, " = ?")[0])
if argIdx < len(args) {
filters = append(filters, []any{fieldName, "==", args[argIdx]})
argIdx++
}
case strings.Contains(cond, " > ?"):
fieldName := extractLastWord(strings.Split(cond, " > ?")[0])
if argIdx < len(args) {
filters = append(filters, []any{fieldName, ">", args[argIdx]})
argIdx++
}
case strings.Contains(cond, " < ?"):
fieldName := extractLastWord(strings.Split(cond, " < ?")[0])
if argIdx < len(args) {
filters = append(filters, []any{fieldName, "<", args[argIdx]})
argIdx++
}
case strings.Contains(cond, " LIKE ?"):
fieldName := extractLastWord(strings.Split(cond, " LIKE ?")[0])
if argIdx < len(args) {
filters = append(filters, []any{fieldName, "LIKE", args[argIdx]})
argIdx++
}
}
}
return filters
}
func splitTopLevelAND(whereClause string) []string {
if whereClause == "" {
return nil
}
parts := []string{}
depth := 0
start := 0
for i := 0; i < len(whereClause); i++ {
switch whereClause[i] {
case '(':
depth++
case ')':
if depth > 0 {
depth--
}
}
if depth == 0 && strings.HasPrefix(whereClause[i:], " AND ") {
parts = append(parts, strings.TrimSpace(whereClause[start:i]))
start = i + len(" AND ")
i += len(" AND ") - 1
}
}
parts = append(parts, strings.TrimSpace(whereClause[start:]))
return parts
}
func trimWrappingParens(s string) string {
s = strings.TrimSpace(s)
for strings.HasPrefix(s, "(") && strings.HasSuffix(s, ")") {
s = strings.TrimSpace(s[1 : len(s)-1])
}
return s
}
// extractLastWord extracts the last word from a string (field name)
func extractLastWord(s string) string {
s = strings.TrimSpace(s)
words := strings.Fields(s)
if len(words) == 0 {
return ""
}
return words[len(words)-1]
}
// parseOrderBy converts SQL ORDER BY to EVA format
// SQL: "created_at DESC, name ASC" -> EVA: ["-created_at", "name"]
func parseOrderBy(orderStr string) []string {
result := []string{}
parts := strings.Split(orderStr, ",")
for _, part := range parts {
part = strings.TrimSpace(part)
switch {
case strings.HasSuffix(part, " DESC"):
field := strings.TrimSuffix(part, " DESC")
result = append(result, "-"+strings.TrimSpace(field))
case strings.HasSuffix(part, " ASC"):
field := strings.TrimSuffix(part, " ASC")
result = append(result, strings.TrimSpace(field))
default:
result = append(result, part)
}
}
return result
}
// Helper functions for common Squirrel patterns with EVA compatibility
// Between creates a range filter for EVA using Squirrel's And combinator
// Example: qb.Where(Between("cmf_created_at", "2024-01-01", "2024-12-31"))
func Between(col string, from, to any) sq.And {
return sq.And{
sq.GtOrEq{col: from},
sq.LtOrEq{col: to},
}
}
/**
* This file is part of the raoptimus/evateamclient.go library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/evateamclient.go/blob/master/LICENSE.md
* @link https://github.com/raoptimus/evateamclient.go
*/
package evateamclient
import "github.com/gofrs/uuid"
type RPCRequest struct {
JSONRPC string `json:"jsonrpc"`
Method string `json:"method"`
CallID string `json:"callid"`
Args interface{} `json:"args,omitempty"`
Kwargs interface{} `json:"kwargs,omitempty"`
}
var (
AllBasicFields = []string{"*"}
AllBasicAndRelationFields = []string{"**"}
AllBasicAndRelationAndM2MFields = []string{"***"}
)
func newCallID() string {
id, err := uuid.NewV7()
if err != nil {
panic(err)
}
return id.String()
}
/**
* This file is part of the raoptimus/evateamclient.go library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/evateamclient.go/blob/master/LICENSE.md
* @link https://github.com/raoptimus/evateamclient.go
*/
package slogadapter
import (
"context"
"log/slog"
)
type Slog struct {
logger *slog.Logger
}
func New(logger *slog.Logger) *Slog {
if logger == nil {
logger = slog.Default()
}
return &Slog{logger: logger}
}
func (a *Slog) Debug(ctx context.Context, msg string, args ...any) {
a.logger.DebugContext(ctx, msg, args...)
}
func (a *Slog) Info(ctx context.Context, msg string, args ...any) {
a.logger.InfoContext(ctx, msg, args...)
}
func (a *Slog) Warn(ctx context.Context, msg string, args ...any) {
a.logger.WarnContext(ctx, msg, args...)
}
func (a *Slog) Error(ctx context.Context, msg string, args ...any) {
a.logger.ErrorContext(ctx, msg, args...)
}
func (a *Slog) WithAttrs(attrs ...slog.Attr) *Slog {
return &Slog{
logger: a.logger.With(attrsToAny(attrs)...),
}
}
func (a *Slog) WithGroup(name string) *Slog {
return &Slog{
logger: a.logger.WithGroup(name),
}
}
func attrsToAny(attrs []slog.Attr) []any {
result := make([]any, len(attrs))
for i, attr := range attrs {
result[i] = attr
}
return result
}
/**
* This file is part of the raoptimus/evateamclient.go library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/evateamclient.go/blob/master/LICENSE.md
* @link https://github.com/raoptimus/evateamclient.go
*/
package evateamclient
import (
"context"
"errors"
"sort"
"strings"
"time"
sq "github.com/Masterminds/squirrel"
"github.com/raoptimus/evateamclient.go/models"
)
// TasksCount returns total tasks matching filters.
func (c *Client) TasksCount(ctx context.Context, kwargs map[string]any) (int64, *models.Meta, error) {
if len(kwargs) == 0 {
kwargs = make(map[string]any)
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: "CmfTask.count",
CallID: newCallID(),
Kwargs: kwargs,
}
var resp models.CountResponse
if err := c.doRequest(ctx, reqBody, &resp); err != nil {
return 0, nil, err
}
return resp.Result, &resp.Meta, nil
}
// ProjectTasksCount returns total tasks in project.
func (c *Client) ProjectTasksCount(ctx context.Context, projectID string) (int64, *models.Meta, error) {
kwargs := map[string]any{
"filter": []any{TaskFieldProjectID, "==", projectID},
}
return c.TasksCount(ctx, kwargs)
}
// SprintTasksCount returns total tasks in sprint by list code.
func (c *Client) SprintTasksCount(ctx context.Context, sprintCode string) (int64, *models.Meta, error) {
kwargs := map[string]any{
"filter": []any{TaskFieldLists, "IN", []string{sprintCode}},
}
return c.TasksCount(ctx, kwargs)
}
// ListTasksCount returns total tasks in list (sprint/release) by list code.
func (c *Client) ListTasksCount(ctx context.Context, listCode string) (int64, *models.Meta, error) {
kwargs := map[string]any{
"filter": []any{TaskFieldLists, "contains", listCode},
}
return c.TasksCount(ctx, kwargs)
}
// SprintStats retrieves sprint statistics.
func (c *Client) SprintStats(ctx context.Context, sprintCode string) (*models.SprintStats, error) {
tasks, _, err := c.SprintTasks(ctx, sprintCode, []string{TaskFieldCacheStatusType})
if err != nil {
return nil, err
}
stats := &models.SprintStats{
SprintID: sprintCode,
TotalTasks: len(tasks),
}
statusCount := make(map[string]int)
for i := range tasks {
statusCount[tasks[i].CacheStatusType]++
}
stats.TasksByStatus = statusCount
return stats, nil
}
// ProjectStats retrieves project statistics.
func (c *Client) ProjectStats(ctx context.Context, projectID string) (*models.ProjectStats, *models.Meta, error) {
stats := &models.ProjectStats{ProjectID: projectID}
// Total tasks
count, _, err := c.ProjectTasksCount(ctx, projectID)
if err == nil {
stats.TotalTasks = int(count)
}
// Open tasks
qb := NewQueryBuilder().
From(EntityTask).
Where(sq.Eq{TaskFieldProjectID: projectID}).
Where(sq.Eq{TaskFieldCacheStatusType: models.StatusTypeOpen})
openCount, err := c.TaskCount(ctx, qb)
if err == nil {
stats.OpenTasks = openCount
}
// Active sprints
sprints, _, err := c.OpenProjectSprints(ctx, projectID, []string{ListFieldID})
if err == nil {
stats.ActiveSprints = len(sprints)
}
// Total users (from project executors)
qb = NewQueryBuilder().
Select("executors").
From(EntityProject).
Where(sq.Eq{ProjectFieldID: projectID}).
Limit(1)
project, _, err := c.ProjectQuery(ctx, qb)
if err == nil && project != nil {
stats.TotalUsers = len(project.Executors)
}
return stats, nil, nil
}
// TimeSpentStatsParams contains parameters for time spent stats aggregation.
type TimeSpentStatsParams struct {
ProjectID string // required: "CmfProject:uuid"
DateFrom string // optional: "2025-01-01"
DateTo string // optional: "2025-12-31"
}
// SprintExecutorsKPIParams contains KPI report settings for sprint executors.
type SprintExecutorsKPIParams struct {
// SprintCode is optional (example: "SPR-001543").
SprintCode string
// ProjectCode is required; if set, must match project.
ProjectCode string
// SprintStartDate optionally.
SprintStartDate time.Time
// SprintEndDate optionally.
SprintEndDate time.Time
}
type sprintKPIExecutorAgg struct {
personID string
baselineTasks int
closedTasks int
taskCodes []string
sprintNames map[string]string
}
// TimeSpentStats retrieves aggregated time spent report grouped by person and task.
func (c *Client) TimeSpentStats(ctx context.Context, params TimeSpentStatsParams) (*models.TimeSpentStats, error) {
logs, err := c.fetchAllProjectTimeLogs(ctx, params)
if err != nil {
return nil, err
}
personIDs := collectUniquePersonIDs(logs)
personNames := c.fetchPersonNames(ctx, personIDs)
return aggregateTimeSpent(logs, personNames, params), nil
}
// SprintExecutorsKPI builds executor KPI for closed tasks in a sprint.
//
// Scope rules:
// - Task belongs to sprint list (`task.lists` contains sprint code or list id)
// - Task closed in sprint date range (`status_closed_at` in [start_date, end_date])
// - Tasks appeared during sprint are excluded:
// - if BaselineTaskIDs provided: only those IDs are counted
// - otherwise: task.cmf_created_at must be <= sprint.start_date
func (c *Client) SprintExecutorsKPI(ctx context.Context, params *SprintExecutorsKPIParams) (*models.SprintExecutorsKPI, error) {
if params.ProjectCode == "" {
return nil, errors.New("project_code is required")
}
project, _, err := c.Project(ctx, params.ProjectCode, []string{ProjectFieldID})
if err != nil {
return nil, err
}
// get sprints
qbLists := NewQueryBuilder().
Select(
ListFieldID,
).
Where(sq.Eq{ListFieldProjectID: project.ID}).
OrderBy(ListFieldID)
if params.SprintCode != "" {
qbLists.Where(sq.Eq{ListFieldCode: params.SprintCode}).Limit(1)
} else {
qbLists.Where(sq.Like{ListFieldCode: models.ListSprintPrefix + "%"})
}
if !params.SprintStartDate.IsZero() {
qbLists.Where(sq.GtOrEq{ListFieldPlanStartDate: params.SprintStartDate})
}
if !params.SprintEndDate.IsZero() {
qbLists.Where(sq.LtOrEq{ListFieldPlanEndDate: params.SprintEndDate})
}
sprints, _, err := c.ListsList(ctx, qbLists)
if err != nil {
return nil, err
}
executorAgg := make(map[string]*sprintKPIExecutorAgg)
baselineTasks := 0
closedTasks := 0
unassignedTasks := 0
for i := range sprints {
qbSprint := NewQueryBuilder().
Select(
ListFieldID,
ListFieldCode,
ListFieldName,
ListFieldProjectID,
ListFieldPlanStartDate,
ListFieldPlanEndDate,
).
Where(sq.Eq{ListFieldID: sprints[i].ID})
sprint, _, err := c.ListQuery(ctx, qbSprint)
if err != nil {
return nil, err
}
qbTasks := NewQueryBuilder().
Select(
TaskFieldID,
TaskFieldCode,
TaskFieldName,
TaskFieldCmfCreatedAt,
TaskFieldStatusClosedAt,
TaskFieldCacheStatusType,
TaskFieldLists,
).
Where(sq.Eq{TaskFieldProjectID: project.ID}).
Where(sq.Eq{TaskFieldLists: []string{sprint.ID}}).
OrderBy(TaskFieldID)
allSprintTasks, _, err := c.TasksList(ctx, qbTasks)
if err != nil {
return nil, err
}
for k := range allSprintTasks {
task := allSprintTasks[k]
// find assignee for task
assigneeID, err := c.resolveTaskFirstInProgressOwner(ctx, task.ID)
if err != nil {
return nil, err
}
if assigneeID == "" {
topLoggerID, err := c.resolveTaskTopLoggerDuringSprint(ctx, task.ID, sprint.PlanStartDate, sprint.PlanEndDate)
if err != nil {
return nil, err
}
assigneeID = topLoggerID
}
if assigneeID == "" {
unassignedTasks++
continue
}
agg, ok := executorAgg[assigneeID]
if !ok {
agg = &sprintKPIExecutorAgg{
personID: assigneeID,
sprintNames: make(map[string]string),
taskCodes: make([]string, 0),
}
executorAgg[assigneeID] = agg
}
agg.taskCodes = append(agg.taskCodes, task.Code)
agg.sprintNames[sprint.ID] = sprint.Name
agg.baselineTasks++
baselineTasks++
if task.IsClosedBetween(sprint.PlanStartDate, sprint.PlanEndDate) {
agg.closedTasks++
closedTasks++
}
}
}
personIDs := make([]string, 0, len(executorAgg))
for personID := range executorAgg {
personIDs = append(personIDs, personID)
}
sort.Strings(personIDs)
personNames := c.fetchPersonNames(ctx, personIDs)
report := &models.SprintExecutorsKPI{
ProjectCode: params.ProjectCode,
SprintCode: params.SprintCode,
SprintStartDate: params.SprintStartDate,
SprintEndDate: params.SprintEndDate,
BaselineTasks: baselineTasks,
ExcludedNewTasks: 0,
TotalClosedTasks: closedTasks,
UnassignedClosed: unassignedTasks,
}
report.Executors = make([]models.SprintExecutorKPIEntry, 0, len(executorAgg))
for _, personID := range personIDs {
agg := executorAgg[personID]
sort.Strings(agg.taskCodes)
personName := personNames[personID]
if personName == "" {
personName = personID
}
report.Executors = append(report.Executors, models.SprintExecutorKPIEntry{
PersonID: personID,
PersonName: personName,
BaselineTasks: agg.baselineTasks,
ClosedTasks: agg.closedTasks,
TaskCodes: agg.taskCodes,
})
}
return report, nil
}
func (c *Client) resolveTaskFirstInProgressOwner(ctx context.Context, taskID string) (string, error) {
comment, _, err := c.CommentQuery(ctx,
NewQueryBuilder().
Select(CommentFieldAuthorID).
From(EntityComment).
Where(sq.Eq{CommentFieldText: "Работа начата"}).
Where(sq.Eq{CommentFieldParentID: taskID}).
OrderBy(CommentFieldCmfCreatedAt+" DESC").
Limit(1),
)
if err != nil {
return "", err
}
return comment.AuthorID, nil
}
func (c *Client) resolveTaskTopLoggerDuringSprint(ctx context.Context, taskID string, dateFrom, dateTo time.Time) (string, error) {
timeByPerson := make(map[string]int)
for offset := 0; ; offset += timeLogPageSize {
filters := [][]any{
{TimeLogFieldParentID, "==", taskID},
}
if !dateFrom.IsZero() {
filters = append(filters, []any{TimeLogFieldCmfCreatedAt, ">=", dateFrom})
}
if !dateTo.IsZero() {
filters = append(filters, []any{TimeLogFieldCmfCreatedAt, "<=", dateTo})
}
kwargs := map[string]any{
"filter": filters,
"fields": []string{
TimeLogFieldID,
TimeLogFieldTimeSpent,
TimeLogFieldCmfOwnerID,
},
"order_by": []string{"-" + TimeLogFieldCmfCreatedAt},
"slice": []int{offset, offset + timeLogPageSize},
}
page, _, err := c.TimeLogs(ctx, kwargs)
if err != nil {
return "", err
}
for i := range page {
personID := strings.TrimSpace(page[i].CmfOwnerID)
if personID == "" {
continue
}
timeByPerson[personID] += page[i].TimeSpent
}
if len(page) < timeLogPageSize {
break
}
}
if len(timeByPerson) == 0 {
return "", nil
}
topPersonID := ""
topMinutes := -1
for personID, minutes := range timeByPerson {
if minutes > topMinutes {
topPersonID = personID
topMinutes = minutes
continue
}
if minutes == topMinutes && personID < topPersonID {
topPersonID = personID
}
}
return topPersonID, nil
}
const timeLogPageSize = 200
func (c *Client) fetchAllProjectTimeLogs(ctx context.Context, params TimeSpentStatsParams) ([]models.TimeLog, error) {
var allLogs []models.TimeLog
for offset := 0; ; offset += timeLogPageSize {
filters := [][]any{
{"parent.project_id", "==", params.ProjectID},
}
if params.DateFrom != "" {
filters = append(filters, []any{"cmf_created_at", ">=", params.DateFrom})
}
if params.DateTo != "" {
filters = append(filters, []any{"cmf_created_at", "<=", params.DateTo})
}
kwargs := map[string]any{
"filter": filters,
"fields": []string{"id", "time_spent", "cmf_owner_id", "parent", "parent_id", "cmf_created_at"},
"order_by": []string{"-cmf_created_at"},
"slice": []int{offset, offset + timeLogPageSize},
}
page, _, err := c.TimeLogs(ctx, kwargs)
if err != nil {
return nil, err
}
allLogs = append(allLogs, page...)
if len(page) < timeLogPageSize {
break
}
}
return allLogs, nil
}
func collectUniquePersonIDs(logs []models.TimeLog) []string {
seen := make(map[string]struct{})
var ids []string
for i := range logs {
pid := logs[i].CmfOwnerID
if pid == "" {
continue
}
if _, ok := seen[pid]; !ok {
seen[pid] = struct{}{}
ids = append(ids, pid)
}
}
return ids
}
func (c *Client) fetchPersonNames(ctx context.Context, ids []string) map[string]string {
names := make(map[string]string, len(ids))
for _, id := range ids {
person, _, err := c.Person(ctx, id, []string{"id", "name"})
if err != nil {
names[id] = id
continue
}
names[id] = person.Name
}
return names
}
func aggregateTimeSpent(logs []models.TimeLog, personNames map[string]string, params TimeSpentStatsParams) *models.TimeSpentStats {
personTasks := make(map[string]map[string]*models.TimeSpentTaskEntry)
for i := range logs {
log := &logs[i]
pid := log.CmfOwnerID
if pid == "" {
continue
}
taskID := log.ParentID
var taskCode, taskName string
if log.Parent != nil {
taskCode = log.Parent.Code
taskName = log.Parent.Name
}
tasks, ok := personTasks[pid]
if !ok {
tasks = make(map[string]*models.TimeSpentTaskEntry)
personTasks[pid] = tasks
}
entry, ok := tasks[taskID]
if !ok {
entry = &models.TimeSpentTaskEntry{
TaskID: taskID,
TaskCode: taskCode,
TaskName: taskName,
}
tasks[taskID] = entry
}
entry.TimeSpent += log.TimeSpent
}
persons := make([]models.TimeSpentPersonEntry, 0, len(personTasks))
grandTotal := 0
for pid, tasks := range personTasks {
var taskEntries []models.TimeSpentTaskEntry
personTotal := 0
for _, entry := range tasks {
taskEntries = append(taskEntries, *entry)
personTotal += entry.TimeSpent
}
sort.Slice(taskEntries, func(i, j int) bool {
return taskEntries[i].TimeSpent > taskEntries[j].TimeSpent
})
name := personNames[pid]
if name == "" {
name = pid
}
persons = append(persons, models.TimeSpentPersonEntry{
PersonID: pid,
PersonName: name,
Tasks: taskEntries,
TotalTime: personTotal,
})
grandTotal += personTotal
}
sort.Slice(persons, func(i, j int) bool {
return persons[i].TotalTime > persons[j].TotalTime
})
return &models.TimeSpentStats{
ProjectID: params.ProjectID,
DateFrom: params.DateFrom,
DateTo: params.DateTo,
Persons: persons,
GrandTotalTime: grandTotal,
}
}
/**
* This file is part of the raoptimus/evateamclient.go library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/evateamclient.go/blob/master/LICENSE.md
* @link https://github.com/raoptimus/evateamclient.go
*/
package evateamclient
import (
"context"
sq "github.com/Masterminds/squirrel"
"github.com/pkg/errors"
"github.com/raoptimus/evateamclient.go/models"
)
// StatusHistory field constants for type-safe queries
const (
// Core fields
StatusHistoryFieldID = "id"
StatusHistoryFieldClassName = "class_name"
StatusHistoryFieldCode = "code"
StatusHistoryFieldName = "name"
// Relations
StatusHistoryFieldParentID = "parent_id" // entity that changed status
StatusHistoryFieldProjectID = "project_id" // project context
StatusHistoryFieldOldStatus = "old_status" // previous status value
StatusHistoryFieldNewStatus = "new_status" // new status value
StatusHistoryFieldOldStatusID = "old_status_id" // previous status ID
StatusHistoryFieldNewStatusID = "new_status_id" // new status ID
// System
StatusHistoryFieldCmfOwnerID = "cmf_owner_id"
StatusHistoryFieldCmfCreatedAt = "cmf_created_at"
StatusHistoryFieldCmfModifiedAt = "cmf_modified_at"
)
var (
// DefaultStatusHistoryFields - standard projection for single status history queries
DefaultStatusHistoryFields = []string{
StatusHistoryFieldID,
StatusHistoryFieldCode,
StatusHistoryFieldParentID,
StatusHistoryFieldOldStatus,
StatusHistoryFieldNewStatus,
StatusHistoryFieldCmfOwnerID,
StatusHistoryFieldCmfCreatedAt,
}
// DefaultStatusHistoryListFields - optimized for LIST queries (lighter payload)
DefaultStatusHistoryListFields = []string{
StatusHistoryFieldID,
StatusHistoryFieldCode,
StatusHistoryFieldParentID,
StatusHistoryFieldOldStatus,
StatusHistoryFieldNewStatus,
StatusHistoryFieldCmfCreatedAt,
}
)
// StatusHistory retrieves a single status history entry by ID
// Example:
//
// history, meta, err := client.StatusHistory(ctx, "CmfStatusHistory:uuid", nil)
func (c *Client) StatusHistory(
ctx context.Context,
statusHistoryID string,
fields []string,
) (*models.StatusHistory, *models.Meta, error) {
qb := NewQueryBuilder().
Select(fields...).
From(EntityStatusHistory).
Where(sq.Eq{StatusHistoryFieldID: statusHistoryID}).
Limit(1)
return c.StatusHistoryQuery(ctx, qb)
}
// StatusHistoryQuery executes query using QueryBuilder
// Example:
//
// qb := evateamclient.NewQueryBuilder().
// Select("id", "old_status", "new_status", "cmf_created_at").
// From(evateamclient.EntityStatusHistory).
// Where(sq.Eq{"id": "CmfStatusHistory:uuid"})
// history, meta, err := client.StatusHistoryQuery(ctx, qb)
func (c *Client) StatusHistoryQuery(ctx context.Context, qb *QueryBuilder) (*models.StatusHistory, *models.Meta, error) {
kwargs, err := qb.ToKwargs()
if err != nil {
return nil, nil, err
}
// Apply default fields if none specified
if _, hasFields := kwargs["fields"]; !hasFields {
kwargs["fields"] = DefaultStatusHistoryFields
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: "CmfStatusHistory.get",
CallID: newCallID(),
Kwargs: kwargs,
}
var resp models.StatusHistoryResponse
if err := c.doRequest(ctx, reqBody, &resp); err != nil {
return nil, nil, errors.WithMessage(err, "failed to get status history list")
}
return &resp.Result, &resp.Meta, nil
}
// StatusHistoryList retrieves list using QueryBuilder
// Example:
//
// qb := evateamclient.NewQueryBuilder().
// Select("id", "old_status", "new_status", "cmf_created_at").
// From(evateamclient.EntityStatusHistory).
// Where(sq.Eq{"parent_id": "CmfTask:uuid"}).
// OrderBy("-cmf_created_at").
// Offset(0).Limit(100)
// histories, meta, err := client.StatusHistoryList(ctx, qb)
func (c *Client) StatusHistoryList(
ctx context.Context,
qb *QueryBuilder,
) ([]models.StatusHistory, *models.Meta, error) {
kwargs, err := qb.From(EntityStatusHistory).ToKwargs()
if err != nil {
return nil, nil, err
}
// Apply default fields if none specified
if _, hasFields := kwargs["fields"]; !hasFields {
kwargs["fields"] = DefaultStatusHistoryListFields
}
method, err := qb.ToMethod(false)
if err != nil {
return nil, nil, err
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: method,
CallID: newCallID(),
Kwargs: kwargs,
}
var resp models.StatusHistoryListResponse
if err := c.doRequest(ctx, reqBody, &resp); err != nil {
return nil, nil, errors.WithMessage(err, "failed to get status history list")
}
return resp.Result, &resp.Meta, nil
}
// StatusHistoryCount counts status history entries using QueryBuilder
// Example:
//
// qb := evateamclient.NewQueryBuilder().
// From(evateamclient.EntityStatusHistory).
// Where(sq.Eq{"parent_id": "CmfTask:uuid"})
// count, err := client.StatusHistoryCount(ctx, qb)
func (c *Client) StatusHistoryCount(
ctx context.Context,
qb *QueryBuilder,
) (int, error) {
kwargs, err := qb.ToKwargs()
if err != nil {
return 0, err
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: "CmfStatusHistory.count",
CallID: newCallID(),
Kwargs: kwargs,
}
var resp struct {
JSONRPC string `json:"jsonrpc"`
Result int `json:"result"`
}
if err := c.doRequest(ctx, reqBody, &resp); err != nil {
return 0, errors.WithMessage(err, "failed to get status history count")
}
return resp.Result, nil
}
// ProjectStatusHistory retrieves ALL status changes for project entities
// Example:
//
// histories, meta, err := client.ProjectStatusHistory(ctx, "CmfProject:uuid", nil)
func (c *Client) ProjectStatusHistory(
ctx context.Context,
projectID string,
fields []string,
) ([]models.StatusHistory, *models.Meta, error) {
qb := NewQueryBuilder().
Select(fields...).
From(EntityStatusHistory).
Where(sq.Eq{StatusHistoryFieldProjectID: projectID}).
OrderBy("-" + StatusHistoryFieldCmfCreatedAt)
return c.StatusHistoryList(ctx, qb)
}
// Backward compatible methods (using old API)
// StatusHistories retrieves status histories with custom filters (backward compatible)
// Recommended: use StatusHistoryList with NewQueryBuilder() instead
func (c *Client) StatusHistories(
ctx context.Context,
kwargs map[string]any,
) ([]models.StatusHistory, *models.Meta, error) {
if len(kwargs) == 0 {
kwargs = make(map[string]any)
}
if _, hasFields := kwargs["fields"]; !hasFields {
kwargs["fields"] = DefaultStatusHistoryListFields
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: "CmfStatusHistory.list",
CallID: newCallID(),
Kwargs: kwargs,
}
var resp models.StatusHistoryListResponse
if err := c.doRequest(ctx, reqBody, &resp); err != nil {
return nil, nil, errors.WithMessage(err, "failed to get status histories")
}
return resp.Result, &resp.Meta, nil
}
/**
* This file is part of the raoptimus/evateamclient.go library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/evateamclient.go/blob/master/LICENSE.md
* @link https://github.com/raoptimus/evateamclient.go
*/
package evateamclient
import (
"context"
"github.com/pkg/errors"
"github.com/raoptimus/evateamclient.go/models"
)
// Tag field constants for type-safe queries
const (
TagFieldID = "id"
TagFieldClassName = "class_name"
TagFieldName = "name"
TagFieldCode = "code"
TagFieldAlias = "alias"
TagFieldParentID = "parent_id"
TagFieldProjectID = "project_id"
)
// DefaultTagFields - standard projection for Tag queries
var DefaultTagFields = []string{
TagFieldID,
TagFieldClassName,
TagFieldName,
TagFieldCode,
TagFieldAlias,
}
// TagList retrieves tags using a QueryBuilder.
// Example:
//
// qb := evateamclient.NewQueryBuilder().
// Select("id", "code", "name", "alias").
// From(evateamclient.EntityTag).
// Where(sq.Eq{"project_id": "CmfProject:uuid"})
// items, meta, err := client.TagList(ctx, qb)
func (c *Client) TagList(
ctx context.Context,
qb *QueryBuilder,
) ([]models.Tag, *models.Meta, error) {
kwargs, err := qb.ToKwargs()
if err != nil {
return nil, nil, err
}
if _, hasFields := kwargs["fields"]; !hasFields {
kwargs["fields"] = DefaultTagFields
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: "CmfTag.list",
CallID: newCallID(),
Kwargs: kwargs,
}
var resp models.TagListResponse
if err := c.doRequest(ctx, reqBody, &resp); err != nil {
return nil, nil, errors.WithMessage(err, "failed to list tags")
}
return resp.Result, &resp.Meta, nil
}
/**
* This file is part of the raoptimus/evateamclient.go library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/evateamclient.go/blob/master/LICENSE.md
* @link https://github.com/raoptimus/evateamclient.go
*/
package evateamclient
import (
"context"
sq "github.com/Masterminds/squirrel"
"github.com/pkg/errors"
"github.com/raoptimus/evateamclient.go/models"
)
// Task field constants for type-safe queries
const (
// Core fields
TaskFieldID = "id"
TaskFieldClassName = "class_name"
TaskFieldCode = "code"
TaskFieldName = "name"
TaskFieldText = "text"
TaskFieldProjectID = "project_id"
TaskFieldParentID = "parent_id"
TaskFieldParentTask = "parent_task"
TaskFieldParentTaskID = "parent_task_id"
TaskFieldCacheStatusType = "cache_status_type"
TaskFieldPriority = "priority"
TaskFieldDeadline = "deadline"
TaskFieldMark = "mark"
TaskFieldAlarmDate = "alarm_date"
// Story Points
TaskFieldAgileStoryPoints = "agile_story_points"
// Relations (single)
TaskFieldResponsible = "responsible"
TaskFieldResponsibleID = "responsible_id"
TaskFieldWaitingFor = "waiting_for"
TaskFieldEpic = "epic"
TaskFieldEpicID = "epic_id"
TaskFieldLogicType = "logic_type"
TaskFieldLogicTypeID = "logic_type_id"
TaskFieldCmfOwnerID = "cmf_owner_id"
TaskFieldWorkflowID = "workflow_id"
TaskFieldStatusID = "status_id"
TaskFieldStatus = "status"
// Relations (arrays)
TaskFieldLists = "lists" // sprints
TaskFieldFixVersions = "fix_versions"
TaskFieldTags = "tags"
TaskFieldExecutors = "executors"
TaskFieldSpectators = "spectators"
TaskFieldComponents = "components"
// Status tracking
TaskFieldStatusModifiedAt = "status_modified_at"
TaskFieldStatusInProgressStart = "status_in_progress_start"
TaskFieldStatusInProgressEnd = "status_in_progress_end"
TaskFieldStatusReviewAt = "status_review_at"
TaskFieldStatusClosedAt = "status_closed_at"
// Planning dates
TaskFieldPlanStartDate = "plan_start_date"
TaskFieldPlanEndDate = "plan_end_date"
TaskFieldPeriodInterval = "period_interval"
TaskFieldPeriodNextDate = "period_next_date"
// Flags
TaskFieldApproved = "approved"
TaskFieldIsPublic = "is_public"
TaskFieldNoControl = "no_control"
TaskFieldIsFlagged = "is_flagged"
// System
TaskFieldCmfCreatedAt = "cmf_created_at"
TaskFieldCmfModifiedAt = "cmf_modified_at"
TaskFieldCmfViewedAt = "cmf_viewed_at"
TaskFieldCmfDeleted = "cmf_deleted"
TaskFieldCmfVersion = "cmf_version"
TaskFieldCmfLockedAt = "cmf_locked_at"
TaskFieldCacheChildTasksCount = "cache_child_tasks_count"
TaskFieldExtID = "ext_id"
TaskFieldArchiveDate = "archiveddate"
TaskFieldResultText = "result_text"
)
var (
// DefaultTaskFields - standard projection for single task queries
DefaultTaskFields = []string{
TaskFieldID,
TaskFieldCode,
TaskFieldName,
TaskFieldText,
TaskFieldProjectID,
TaskFieldLists,
TaskFieldCmfOwnerID,
TaskFieldResponsible,
TaskFieldCacheStatusType,
TaskFieldPriority,
TaskFieldDeadline,
TaskFieldEpic,
TaskFieldTags,
TaskFieldExecutors,
TaskFieldWaitingFor,
TaskFieldParentID,
TaskFieldFixVersions,
TaskFieldAgileStoryPoints,
TaskFieldComponents,
TaskFieldLogicType,
TaskFieldStatusID,
}
// DefaultTaskListFields - optimized for LIST queries (lighter payload)
DefaultTaskListFields = []string{
TaskFieldID,
TaskFieldCode,
TaskFieldName,
TaskFieldProjectID,
TaskFieldCacheStatusType,
TaskFieldPriority,
TaskFieldDeadline,
TaskFieldResponsibleID,
TaskFieldEpicID,
TaskFieldAgileStoryPoints,
TaskFieldStatusID,
}
)
// Task retrieves a single task by code (backward compatible)
// Example:
//
// task, meta, err := client.Task(ctx, "PROJ-123", nil)
func (c *Client) Task(
ctx context.Context,
taskCode string,
fields []string,
) (*models.Task, *models.Meta, error) {
qb := NewQueryBuilder().
Select(fields...).
From(EntityTask).
Where(sq.Eq{TaskFieldCode: taskCode}).
Limit(1)
return c.TaskQuery(ctx, qb)
}
// TaskQuery executes query using REAL Squirrel API
// Example:
//
// qb := evateamclient.NewQueryBuilder().
// Select("id", "code", "name", "responsible", "executors").
// From(evateamclient.EntityTask).
// Where(sq.Eq{"code": "PROJ-123"})
// task, meta, err := client.TaskQuery(ctx, qb)
func (c *Client) TaskQuery(ctx context.Context, qb *QueryBuilder) (*models.Task, *models.Meta, error) {
kwargs, err := qb.From(EntityTask).ToKwargs()
if err != nil {
return nil, nil, err
}
// Apply default fields if none specified
if _, hasFields := kwargs["fields"]; !hasFields {
kwargs["fields"] = DefaultTaskFields
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: "CmfTask.get",
CallID: newCallID(),
Kwargs: kwargs,
}
var resp models.TaskResponse
if err := c.doRequest(ctx, reqBody, &resp); err != nil {
return nil, nil, err
}
return &resp.Result, &resp.Meta, nil
}
// TasksList retrieves list using REAL Squirrel
// Example:
//
// qb := evateamclient.NewQueryBuilder().
// Select("id", "code", "name", "priority", "cache_status_type").
// From(evateamclient.EntityTask).
// Where(sq.Eq{"project_id": "Project:uuid"}).
// Where(sq.Eq{"cache_status_type": evateamclient.StatusTypeOpen}).
// OrderBy("-priority", "name").
// Offset(0).Limit(100)
// tasks, meta, err := client.TasksList(ctx, qb)
func (c *Client) TasksList(
ctx context.Context,
qb *QueryBuilder,
) ([]models.TaskBrowse, *models.Meta, error) {
kwargs, err := qb.From(EntityTask).ToKwargs()
if err != nil {
return nil, nil, err
}
// Apply default fields if none specified
if _, hasFields := kwargs["fields"]; !hasFields {
kwargs["fields"] = DefaultTaskListFields
}
method, err := qb.ToMethod(false)
if err != nil {
return nil, nil, err
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: method,
CallID: newCallID(),
Kwargs: kwargs,
}
var resp models.TaskListResponse
if err := c.doRequest(ctx, reqBody, &resp); err != nil {
return nil, nil, errors.WithMessage(err, "failed to get tasks")
}
return resp.Result, &resp.Meta, nil
}
// TaskCount counts using REAL Squirrel
// Example:
//
// qb := evateamclient.NewQueryBuilder().
// From(evateamclient.EntityTask).
// Where(sq.Eq{"project_id": "Project:uuid"}).
// Where(sq.Eq{"cache_status_type": evateamclient.StatusTypeOpen})
// count, err := client.TaskCount(ctx, qb)
func (c *Client) TaskCount(
ctx context.Context,
qb *QueryBuilder,
) (int, error) {
kwargs, err := qb.ToKwargs()
if err != nil {
return 0, err
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: "CmfTask.count",
CallID: newCallID(),
Kwargs: kwargs,
}
var resp struct {
JSONRPC string `json:"jsonrpc"`
Result int `json:"result"`
}
if err := c.doRequest(ctx, reqBody, &resp); err != nil {
return 0, errors.WithMessage(err, "failed to get task count")
}
return resp.Result, nil
}
// ProjectTasks retrieves ALL tasks for project (backward compatible)
// Example:
//
// tasks, meta, err := client.ProjectTasks(ctx, "Project:uuid", nil)
func (c *Client) ProjectTasks(
ctx context.Context,
projectID string,
fields []string,
) ([]models.TaskBrowse, *models.Meta, error) {
qb := NewQueryBuilder().
Select(fields...).
From(EntityTask).
Where(sq.Eq{TaskFieldParentID: projectID})
return c.TasksList(ctx, qb)
}
// SprintTasks retrieves ALL tasks for sprint (backward compatible)
// Example:
//
// tasks, meta, err := client.SprintTasks(ctx, "SPRINT-CODE", nil)
func (c *Client) SprintTasks(
ctx context.Context,
sprintCode string,
fields []string,
) ([]models.TaskBrowse, *models.Meta, error) {
if len(fields) == 0 {
fields = DefaultTaskListFields
}
// Note: "contains" operator is EVA-specific, not standard SQL
// Using raw kwargs for this special case
kwargs := map[string]any{
"filter": []any{TaskFieldLists, "IN", []string{sprintCode}},
"fields": fields,
}
return c.Tasks(ctx, kwargs)
}
// PersonTasks retrieves ALL tasks where user is responsible
// Note: For OR logic (responsible OR executor), use PersonTasksAll
// Example:
//
// tasks, meta, err := client.PersonTasks(ctx, "Person:uuid", nil)
func (c *Client) PersonTasks(
ctx context.Context,
userID string,
fields []string,
) ([]models.TaskBrowse, *models.Meta, error) {
qb := NewQueryBuilder().
Select(fields...).
From(EntityTask).
Where(sq.Eq{TaskFieldResponsible: userID})
return c.TasksList(ctx, qb)
}
// PersonTasksAsExecutor retrieves tasks where user is in executors list
// Example:
//
// tasks, meta, err := client.PersonTasksAsExecutor(ctx, "Person:uuid", nil)
func (c *Client) PersonTasksAsExecutor(
ctx context.Context,
userID string,
fields []string,
) ([]models.TaskBrowse, *models.Meta, error) {
if len(fields) == 0 {
fields = DefaultTaskListFields
}
// Note: "contains" operator is EVA-specific, not standard SQL
kwargs := map[string]any{
"filter": []any{TaskFieldExecutors, "contains", userID},
"fields": fields,
}
return c.Tasks(ctx, kwargs)
}
// PersonProjectTasks retrieves user's tasks as responsible in specific project
// Example:
//
// tasks, meta, err := client.PersonProjectTasks(ctx, "Project:uuid", "Person:uuid", nil)
func (c *Client) PersonProjectTasks(
ctx context.Context,
projectID,
userID string,
fields []string,
) ([]models.TaskBrowse, *models.Meta, error) {
qb := NewQueryBuilder().
Select(fields...).
From(EntityTask).
Where(sq.Eq{TaskFieldProjectID: projectID}).
Where(sq.Eq{TaskFieldResponsible: userID})
return c.TasksList(ctx, qb)
}
// Backward compatible methods (using old API)
// Tasks retrieves tasks with custom filters (backward compatible, deprecated)
// Recommended: use TasksList with NewQueryBuilder() instead
func (c *Client) Tasks(ctx context.Context, kwargs map[string]any) ([]models.TaskBrowse, *models.Meta, error) {
if len(kwargs) == 0 {
kwargs = make(map[string]any)
}
if _, hasFields := kwargs["fields"]; !hasFields {
kwargs["fields"] = DefaultTaskListFields
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: "CmfTask.list",
CallID: newCallID(),
Kwargs: kwargs,
}
var resp models.TaskListResponse
if err := c.doRequest(ctx, reqBody, &resp); err != nil {
return nil, nil, err
}
return resp.Result, &resp.Meta, nil
}
// CRUD Operations
// TaskCreateParams contains parameters for creating a new task.
//
// ProjectID accepts a project ID (e.g. "CmfProject:uuid") or a project code
// (e.g. "epud") — both are valid for the server's `parent` field.
// Epic and ParentTask accept TASK CODES (e.g. "TASK-000777", "EPC-000123"),
// not IDs, per the EVA API contract.
// LogicTypeID accepts a LogicType ID (e.g. "CmfLogicType:uuid"); use
// LogicTypeByCode to resolve a code to an ID.
type TaskCreateParams struct {
Name string `json:"name"`
ProjectID string `json:"project_id"`
Text string `json:"text,omitempty"`
Priority int `json:"priority,omitempty"`
Deadline string `json:"deadline,omitempty"`
Responsible string `json:"responsible,omitempty"`
Executors []string `json:"executors,omitempty"`
Tags []string `json:"tags,omitempty"`
Lists []string `json:"lists,omitempty"` // sprints
Epic string `json:"epic,omitempty"`
ParentTask string `json:"parent_task,omitempty"`
LogicTypeID string `json:"logic_type_id,omitempty"`
}
// TaskCreate creates a new task
// Example:
//
// params := evateamclient.TaskCreateParams{
// Name: "New Task",
// ProjectID: "Project:uuid",
// Priority: 3,
// }
// task, err := client.TaskCreate(ctx, params)
func (c *Client) TaskCreate(
ctx context.Context,
params *TaskCreateParams,
) (*models.Task, error) {
kwargs := map[string]any{
"name": params.Name,
"parent": params.ProjectID,
}
if params.Text != "" {
kwargs["text"] = params.Text
}
if params.Priority > 0 {
kwargs["priority"] = params.Priority
}
if params.Deadline != "" {
kwargs["deadline"] = params.Deadline
}
if params.Responsible != "" {
kwargs["responsible"] = params.Responsible
}
if len(params.Executors) > 0 {
kwargs["executors"] = params.Executors
}
if len(params.Tags) > 0 {
kwargs["tags"] = params.Tags
}
if len(params.Lists) > 0 {
kwargs["lists"] = params.Lists
}
if params.Epic != "" {
kwargs["epic"] = params.Epic
}
if params.ParentTask != "" {
kwargs["parent_task"] = params.ParentTask
}
if params.LogicTypeID != "" {
kwargs["logic_type"] = params.LogicTypeID
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: "CmfTask.create",
CallID: newCallID(),
Kwargs: kwargs,
}
var resp struct {
JSONRPC string `json:"jsonrpc"`
Result string `json:"result"`
}
if err := c.doRequest(ctx, reqBody, &resp); err != nil {
return nil, err
}
if resp.Result == "" {
return nil, errors.New("CmfTask.create returned empty id")
}
qb := NewQueryBuilder().
Select(DefaultTaskFields...).
From(EntityTask).
Where(sq.Eq{TaskFieldID: resp.Result}).
Limit(1)
task, _, err := c.TaskQuery(ctx, qb)
if err != nil {
return nil, errors.WithMessagef(err, "fetch created task %s", resp.Result)
}
return task, nil
}
// TaskUpdate updates an existing task
// Example:
//
// updates := map[string]any{
// "name": "Updated Task Name",
// "priority": 5,
// }
// task, err := client.TaskUpdate(ctx, "CmfTask:uuid", updates)
func (c *Client) TaskUpdate(
ctx context.Context,
taskID string,
updates map[string]any,
) (*models.Task, error) {
if taskID == "" {
return nil, errors.New("taskID is required")
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: "CmfTask.update",
CallID: newCallID(),
Args: []any{taskID},
Kwargs: updates,
}
var resp struct {
JSONRPC string `json:"jsonrpc"`
Result string `json:"result"`
}
if err := c.doRequest(ctx, reqBody, &resp); err != nil {
return nil, err
}
if resp.Result == "" {
return nil, errors.New("CmfTask.update returned empty id")
}
qb := NewQueryBuilder().
Select(DefaultTaskFields...).
From(EntityTask).
Where(sq.Eq{TaskFieldID: resp.Result}).
Limit(1)
task, _, err := c.TaskQuery(ctx, qb)
if err != nil {
return nil, errors.WithMessagef(err, "fetch updated task %s", resp.Result)
}
return task, nil
}
// TaskUpdateStatus updates task status (workflow transition)
// Example:
//
// task, err := client.TaskUpdateStatus(ctx, "CmfTask:uuid", "CLOSED")
func (c *Client) TaskUpdateStatus(
ctx context.Context,
taskID string,
status string,
) (*models.Task, error) {
return c.TaskUpdate(ctx, taskID, map[string]any{
"cache_status_type": status,
})
}
// TaskDelete deletes a task by ID
// Example:
//
// err := client.TaskDelete(ctx, "CmfTask:uuid")
func (c *Client) TaskDelete(
ctx context.Context,
taskID string,
) error {
if taskID == "" {
return errors.New("taskID is required")
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: "CmfTask.delete",
CallID: newCallID(),
Args: []any{taskID},
}
var resp struct {
JSONRPC string `json:"jsonrpc"`
Result any `json:"result"`
}
return c.doRequest(ctx, reqBody, &resp)
}
// TaskArchive archives a task (soft delete)
// Example:
//
// err := client.TaskArchive(ctx, "CmfTask:uuid")
func (c *Client) TaskArchive(
ctx context.Context,
taskID string,
) error {
_, err := c.TaskUpdate(ctx, taskID, map[string]any{
"cmf_deleted": true,
})
return err
}
/**
* This file is part of the raoptimus/evateamclient.go library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/evateamclient.go/blob/master/LICENSE.md
* @link https://github.com/raoptimus/evateamclient.go
*/
package evateamclient
import (
"context"
sq "github.com/Masterminds/squirrel"
"github.com/raoptimus/evateamclient.go/models"
)
// TaskLink field constants for type-safe queries
const (
// Core fields
TaskLinkFieldID = "id"
TaskLinkFieldClassName = "class_name"
TaskLinkFieldCode = "code"
TaskLinkFieldName = "name"
TaskLinkFieldInLink = "in_link" // filter field: incoming links to task
TaskLinkFieldOutLink = "out_link" // filter field: outgoing links from task
// System
TaskLinkFieldCmfCreatedAt = "cmf_created_at"
TaskLinkFieldCmfModifiedAt = "cmf_modified_at"
TaskLinkFieldCmfOwnerID = "cmf_owner_id"
)
var (
// DefaultTaskLinkFields - standard projection for task link queries
DefaultTaskLinkFields = []string{
TaskLinkFieldID,
TaskLinkFieldClassName,
TaskLinkFieldCode,
TaskLinkFieldName,
TaskLinkFieldCmfCreatedAt,
TaskLinkFieldCmfOwnerID,
}
// DefaultTaskLinkListFields - optimized for LIST queries
DefaultTaskLinkListFields = []string{
TaskLinkFieldID,
TaskLinkFieldCode,
TaskLinkFieldName,
}
)
// TaskLink retrieves a single task link by ID
// Example:
//
// link, meta, err := client.TaskLink(ctx, "CmfTaskLink:uuid", nil)
func (c *Client) TaskLink(
ctx context.Context,
linkID string,
fields []string,
) (*models.TaskLink, *models.Meta, error) {
qb := NewQueryBuilder().
Select(fields...).
From(EntityRelation).
Where(sq.Eq{TaskLinkFieldID: linkID}).
Limit(1)
return c.TaskLinkQuery(ctx, qb)
}
// TaskLinkQuery executes query using QueryBuilder
// Example:
//
// qb := evateamclient.NewQueryBuilder().
// Select("id", "code", "name").
// From(evateamclient.EntityRelation).
// Where(sq.Eq{"id": "CmfRelationOption:uuid"})
// link, meta, err := client.TaskLinkQuery(ctx, qb)
func (c *Client) TaskLinkQuery(ctx context.Context, qb *QueryBuilder) (*models.TaskLink, *models.Meta, error) {
kwargs, err := qb.ToKwargs()
if err != nil {
return nil, nil, err
}
// Apply default fields if none specified
if _, hasFields := kwargs["fields"]; !hasFields {
kwargs["fields"] = DefaultTaskLinkFields
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: "CmfRelationOption.get",
CallID: newCallID(),
Kwargs: kwargs,
}
var resp models.TaskLinkResponse
if err := c.doRequest(ctx, reqBody, &resp); err != nil {
return nil, nil, err
}
return &resp.Result, &resp.Meta, nil
}
// TaskLinksListQuery retrieves list using QueryBuilder
// Example:
//
// qb := evateamclient.NewQueryBuilder().
// Select("id", "code", "name").
// From(evateamclient.EntityRelation).
// Where(sq.Eq{evateamclient.TaskLinkFieldOutLink: "CmfTask:uuid"}).
// Limit(100)
// links, meta, err := client.TaskLinksListQuery(ctx, qb)
func (c *Client) TaskLinksListQuery(
ctx context.Context,
qb *QueryBuilder,
) ([]models.TaskLink, *models.Meta, error) {
kwargs, err := qb.ToKwargs()
if err != nil {
return nil, nil, err
}
// Apply default fields if none specified
if _, hasFields := kwargs["fields"]; !hasFields {
kwargs["fields"] = DefaultTaskLinkListFields
}
method, err := qb.ToMethod(false)
if err != nil {
return nil, nil, err
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: method,
CallID: newCallID(),
Kwargs: kwargs,
}
var resp models.TaskLinkListResponse
if err := c.doRequest(ctx, reqBody, &resp); err != nil {
return nil, nil, err
}
return resp.Result, &resp.Meta, nil
}
// TaskLinkCount counts task links using QueryBuilder
// Example:
//
// qb := evateamclient.NewQueryBuilder().
// From(evateamclient.EntityRelation).
// Where(sq.Eq{evateamclient.TaskLinkFieldOutLink: "CmfTask:uuid"})
// count, err := client.TaskLinkCount(ctx, qb)
func (c *Client) TaskLinkCount(
ctx context.Context,
qb *QueryBuilder,
) (int, error) {
kwargs, err := qb.ToKwargs()
if err != nil {
return 0, err
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: "CmfRelationOption.count",
CallID: newCallID(),
Kwargs: kwargs,
}
var resp struct {
JSONRPC string `json:"jsonrpc"`
Result int `json:"result"`
}
if err := c.doRequest(ctx, reqBody, &resp); err != nil {
return 0, err
}
return resp.Result, nil
}
// TaskLinks retrieves ALL task relationships (both directions)
// Makes two API calls (outgoing + incoming) and merges results
// Example:
//
// links, meta, err := client.TaskLinks(ctx, "CmfTask:uuid", nil)
func (c *Client) TaskLinks(
ctx context.Context,
taskID string,
fields []string,
) ([]models.TaskLink, *models.Meta, error) {
// Get outgoing links (task is source)
outgoing, _, err := c.TaskLinksOutgoing(ctx, taskID, fields)
if err != nil {
return nil, nil, err
}
// Get incoming links (task is target)
incoming, meta, err := c.TaskLinksIncoming(ctx, taskID, fields)
if err != nil {
return nil, nil, err
}
// Merge results, avoiding duplicates by ID
seen := make(map[string]bool)
var result []models.TaskLink
for i := range outgoing {
if !seen[outgoing[i].ID] {
seen[outgoing[i].ID] = true
result = append(result, outgoing[i])
}
}
for i := range incoming {
if !seen[incoming[i].ID] {
seen[incoming[i].ID] = true
result = append(result, incoming[i])
}
}
return result, meta, nil
}
// TaskLinksOutgoing retrieves links where task is source (outgoing)
// Example:
//
// links, meta, err := client.TaskLinksOutgoing(ctx, "CmfTask:uuid", nil)
func (c *Client) TaskLinksOutgoing(
ctx context.Context,
taskID string,
fields []string,
) ([]models.TaskLink, *models.Meta, error) {
qb := NewQueryBuilder().
Select(fields...).
From(EntityRelation).
Where(sq.Eq{TaskLinkFieldOutLink: taskID})
return c.TaskLinksListQuery(ctx, qb)
}
// TaskLinksIncoming retrieves links where task is target (incoming)
// Example:
//
// links, meta, err := client.TaskLinksIncoming(ctx, "CmfTask:uuid", nil)
func (c *Client) TaskLinksIncoming(
ctx context.Context,
taskID string,
fields []string,
) ([]models.TaskLink, *models.Meta, error) {
qb := NewQueryBuilder().
Select(fields...).
From(EntityRelation).
Where(sq.Eq{TaskLinkFieldInLink: taskID})
return c.TaskLinksListQuery(ctx, qb)
}
// TaskLinkCreate creates a new task link
// Example:
//
// link, err := client.TaskLinkCreate(ctx, "CmfTask:uuid1", "CmfTask:uuid2", "RLO-000001")
func (c *Client) TaskLinkCreate(
ctx context.Context,
sourceTaskID, targetTaskID, relationOptionID string,
) (*models.TaskLink, error) {
kwargs := map[string]any{
TaskLinkFieldOutLink: sourceTaskID,
TaskLinkFieldInLink: targetTaskID,
"id": relationOptionID,
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: "CmfRelationOption.create",
CallID: newCallID(),
Kwargs: kwargs,
}
var resp models.TaskLinkResponse
if err := c.doRequest(ctx, reqBody, &resp); err != nil {
return nil, err
}
return &resp.Result, nil
}
// TaskLinkDelete deletes a task link by ID
// Example:
//
// err := client.TaskLinkDelete(ctx, "RLO-000001")
func (c *Client) TaskLinkDelete(
ctx context.Context,
linkID string,
) error {
kwargs := map[string]any{
"id": linkID,
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: "CmfRelationOption.delete",
CallID: newCallID(),
Kwargs: kwargs,
}
var resp struct {
JSONRPC string `json:"jsonrpc"`
Result bool `json:"result"`
}
return c.doRequest(ctx, reqBody, &resp)
}
// Backward compatible methods (using old API)
// TaskLinksList retrieves task links with custom filters (backward compatible, deprecated)
// Recommended: use TaskLinksListQuery with NewQueryBuilder() instead
func (c *Client) TaskLinksList(
ctx context.Context,
kwargs map[string]any,
) ([]models.TaskLink, *models.Meta, error) {
if len(kwargs) == 0 {
kwargs = make(map[string]any)
}
if _, hasFields := kwargs["fields"]; !hasFields {
kwargs["fields"] = DefaultTaskLinkListFields
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: "CmfRelationOption.list",
CallID: newCallID(),
Kwargs: kwargs,
}
var resp models.TaskLinkListResponse
if err := c.doRequest(ctx, reqBody, &resp); err != nil {
return nil, nil, err
}
return resp.Result, &resp.Meta, nil
}
/**
* This file is part of the raoptimus/evateamclient.go library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/evateamclient.go/blob/master/LICENSE.md
* @link https://github.com/raoptimus/evateamclient.go
*/
package evateamclient
import (
"context"
sq "github.com/Masterminds/squirrel"
"github.com/raoptimus/evateamclient.go/models"
)
// TimeLog field constants for type-safe queries
const (
// Core fields
TimeLogFieldID = "id"
TimeLogFieldClassName = "class_name"
TimeLogFieldCode = "code"
TimeLogFieldName = "name"
TimeLogFieldTimeSpent = "time_spent"
// Relations
TimeLogFieldParent = "parent" // nested task object
TimeLogFieldParentID = "parent_id" // task ID (CmfTask:uuid)
// System
TimeLogFieldProjectID = "project_id"
TimeLogFieldCmfOwnerID = "cmf_owner_id" // person who logged time
TimeLogFieldCmfCreatedAt = "cmf_created_at"
TimeLogFieldCmfModifiedAt = "cmf_modified_at"
)
var (
// DefaultTimeLogFields - standard projection for single time log queries
DefaultTimeLogFields = []string{
TimeLogFieldID,
TimeLogFieldCode,
TimeLogFieldTimeSpent,
TimeLogFieldParent,
TimeLogFieldParentID,
TimeLogFieldProjectID,
TimeLogFieldCmfOwnerID,
TimeLogFieldCmfCreatedAt,
}
// DefaultTimeLogListFields - optimized for LIST queries (lighter payload)
DefaultTimeLogListFields = []string{
TimeLogFieldID,
TimeLogFieldCode,
TimeLogFieldTimeSpent,
TimeLogFieldParent,
TimeLogFieldCmfCreatedAt,
}
)
// TimeLog retrieves a single time log entry by ID (backward compatible)
// Example:
//
// log, meta, err := client.TimeLog(ctx, "CmfTimeTrackerHistory:uuid", nil)
func (c *Client) TimeLog(
ctx context.Context,
timeLogID string,
fields []string,
) (*models.TimeLog, *models.Meta, error) {
qb := NewQueryBuilder().
Select(fields...).
From(EntityTimeLog).
Where(sq.Eq{TimeLogFieldID: timeLogID}).
Limit(1)
return c.TimeLogQuery(ctx, qb)
}
// TimeLogQuery executes query using REAL Squirrel API
// Example:
//
// qb := evateamclient.NewQueryBuilder().
// Select("id", "time_spent", "author", "description").
// From(evateamclient.EntityTimeLog).
// Where(sq.Eq{"id": "CmfTimeTrackerHistory:uuid"})
// log, meta, err := client.TimeLogQuery(ctx, qb)
func (c *Client) TimeLogQuery(ctx context.Context, qb *QueryBuilder) (*models.TimeLog, *models.Meta, error) {
kwargs, err := qb.ToKwargs()
if err != nil {
return nil, nil, err
}
// Apply default fields if none specified
if _, hasFields := kwargs["fields"]; !hasFields {
kwargs["fields"] = DefaultTimeLogFields
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: "CmfTimeTrackerHistory.get",
CallID: newCallID(),
Kwargs: kwargs,
}
var resp models.TimeLogResponse
if err := c.doRequest(ctx, reqBody, &resp); err != nil {
return nil, nil, err
}
return &resp.Result, &resp.Meta, nil
}
// TimeLogsList retrieves list using REAL Squirrel
// Example:
//
// qb := evateamclient.NewQueryBuilder().
// Select("id", "time_spent", "author", "description").
// From(evateamclient.EntityTimeLog).
// Where(sq.Eq{"parent": "CmfTask:uuid"}).
// OrderBy("-cmf_created_at").
// Offset(0).Limit(100)
// logs, meta, err := client.TimeLogsList(ctx, qb)
func (c *Client) TimeLogsList(
ctx context.Context,
qb *QueryBuilder,
) ([]models.TimeLog, *models.Meta, error) {
kwargs, err := qb.ToKwargs()
if err != nil {
return nil, nil, err
}
// Apply default fields if none specified
if _, hasFields := kwargs["fields"]; !hasFields {
kwargs["fields"] = DefaultTimeLogListFields
}
method, err := qb.ToMethod(false)
if err != nil {
return nil, nil, err
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: method,
CallID: newCallID(),
Kwargs: kwargs,
}
var resp models.TimeLogListResponse
if err := c.doRequest(ctx, reqBody, &resp); err != nil {
return nil, nil, err
}
return resp.Result, &resp.Meta, nil
}
// TimeLogCount counts using REAL Squirrel
// Example:
//
// qb := evateamclient.NewQueryBuilder().
// From(evateamclient.EntityTimeLog).
// Where(sq.Eq{"parent": "CmfTask:uuid"})
// count, err := client.TimeLogCount(ctx, qb)
func (c *Client) TimeLogCount(
ctx context.Context,
qb *QueryBuilder,
) (int, error) {
kwargs, err := qb.ToKwargs()
if err != nil {
return 0, err
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: "CmfTimeTrackerHistory.count",
CallID: newCallID(),
Kwargs: kwargs,
}
var resp struct {
JSONRPC string `json:"jsonrpc"`
Result int `json:"result"`
}
if err := c.doRequest(ctx, reqBody, &resp); err != nil {
return 0, err
}
return resp.Result, nil
}
// TaskTimeLogs retrieves ALL time entries for specific task
// Example:
//
// logs, meta, err := client.TaskTimeLogs(ctx, "CmfTask:uuid", nil)
func (c *Client) TaskTimeLogs(
ctx context.Context,
taskID string,
fields []string,
) ([]models.TimeLog, *models.Meta, error) {
qb := NewQueryBuilder().
Select(fields...).
From(EntityTimeLog).
Where(sq.Eq{TimeLogFieldParentID: taskID}).
OrderBy("-" + TimeLogFieldCmfCreatedAt)
return c.TimeLogsList(ctx, qb)
}
// UserTimeLogs retrieves ALL time entries by specific user
// Example:
//
// logs, meta, err := client.UserTimeLogs(ctx, "CmfPerson:uuid", nil)
func (c *Client) UserTimeLogs(
ctx context.Context,
userID string,
fields []string,
) ([]models.TimeLog, *models.Meta, error) {
qb := NewQueryBuilder().
Select(fields...).
From(EntityTimeLog).
Where(sq.Eq{TimeLogFieldCmfOwnerID: userID}).
OrderBy("-" + TimeLogFieldCmfCreatedAt)
return c.TimeLogsList(ctx, qb)
}
// UserTaskTimeLogs retrieves time entries for task by specific user
// Example:
//
// logs, meta, err := client.UserTaskTimeLogs(ctx, "CmfTask:uuid", "CmfPerson:uuid", nil)
func (c *Client) UserTaskTimeLogs(
ctx context.Context,
taskID,
userID string,
fields []string,
) ([]models.TimeLog, *models.Meta, error) {
qb := NewQueryBuilder().
Select(fields...).
From(EntityTimeLog).
Where(sq.Eq{TimeLogFieldParentID: taskID}).
Where(sq.Eq{TimeLogFieldCmfOwnerID: userID}).
OrderBy("-" + TimeLogFieldCmfCreatedAt)
return c.TimeLogsList(ctx, qb)
}
// ProjectTimeLogs retrieves ALL time entries for project tasks (backward compatible)
// Note: This uses dot notation for nested field filtering
// Example:
//
// logs, meta, err := client.ProjectTimeLogs(ctx, "Project:uuid", nil)
func (c *Client) ProjectTimeLogs(
ctx context.Context,
projectID string,
fields []string,
) ([]models.TimeLog, *models.Meta, error) {
if len(fields) == 0 {
fields = DefaultTimeLogListFields
}
// Using dot notation for nested field filtering
kwargs := map[string]any{
"filter": []any{"parent.project_id", "==", projectID},
"fields": fields,
"order_by": []string{"-" + TimeLogFieldCmfCreatedAt},
}
return c.TimeLogs(ctx, kwargs)
}
// Backward compatible methods (using old API)
// TimeLogs retrieves time logs with custom filters (backward compatible, deprecated)
// Recommended: use TimeLogsList with NewQueryBuilder() instead
func (c *Client) TimeLogs(
ctx context.Context,
kwargs map[string]any,
) ([]models.TimeLog, *models.Meta, error) {
if len(kwargs) == 0 {
kwargs = make(map[string]any)
}
if _, hasFields := kwargs["fields"]; !hasFields {
kwargs["fields"] = DefaultTimeLogListFields
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: "CmfTimeTrackerHistory.list",
CallID: newCallID(),
Kwargs: kwargs,
}
var resp models.TimeLogListResponse
if err := c.doRequest(ctx, reqBody, &resp); err != nil {
return nil, nil, err
}
return resp.Result, &resp.Meta, nil
}
// CRUD Operations
// TimeLogCreateParams contains parameters for creating a new time log entry
type TimeLogCreateParams struct {
ParentID string `json:"parent_id"` // task ID (CmfTask:uuid)
TimeSpent int `json:"time_spent"` // minutes
}
// TimeLogCreate creates a new time log entry
// Example:
//
// log, err := client.TimeLogCreate(ctx, TimeLogCreateParams{
// ParentID: "CmfTask:uuid",
// TimeSpent: 180, // 3 hours in minutes
// })
func (c *Client) TimeLogCreate(
ctx context.Context,
params TimeLogCreateParams,
) (*models.TimeLog, error) {
kwargs := map[string]any{
"parent_id": params.ParentID,
"time_spent": params.TimeSpent,
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: "CmfTimeTrackerHistory.create",
CallID: newCallID(),
Kwargs: kwargs,
}
var resp models.TimeLogResponse
if err := c.doRequest(ctx, reqBody, &resp); err != nil {
return nil, err
}
return &resp.Result, nil
}
// TimeLogUpdate updates an existing time log entry
// Example:
//
// updates := map[string]any{
// "time_spent": 240, // 4 hours in minutes
// }
// log, err := client.TimeLogUpdate(ctx, "CmfTimeTrackerHistory:uuid", updates)
func (c *Client) TimeLogUpdate(
ctx context.Context,
timeLogID string,
updates map[string]any,
) (*models.TimeLog, error) {
kwargs := map[string]any{
"id": timeLogID,
}
for k, v := range updates {
kwargs[k] = v
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: "CmfTimeTrackerHistory.update",
CallID: newCallID(),
Kwargs: kwargs,
}
var resp models.TimeLogResponse
if err := c.doRequest(ctx, reqBody, &resp); err != nil {
return nil, err
}
return &resp.Result, nil
}
// TimeLogDelete deletes a time log entry by ID
// Example:
//
// err := client.TimeLogDelete(ctx, "CmfTimeTrackerHistory:uuid")
func (c *Client) TimeLogDelete(
ctx context.Context,
timeLogID string,
) error {
kwargs := map[string]any{
"id": timeLogID,
}
reqBody := &RPCRequest{
JSONRPC: "2.2",
Method: "CmfTimeTrackerHistory.delete",
CallID: newCallID(),
Kwargs: kwargs,
}
var resp struct {
JSONRPC string `json:"jsonrpc"`
Result bool `json:"result"`
}
return c.doRequest(ctx, reqBody, &resp)
}