From db61753a5b62437d84859d8ffe80260d67249bb3 Mon Sep 17 00:00:00 2001 From: Martin Catty Date: Thu, 18 Jun 2026 15:01:49 -0400 Subject: [PATCH] feat: merge append_content on branch file in gitlab.commit_files Load the target file from the branch API, append the block, and commit the merged body. Honor project_path on create_branch, commit_files, and create_mr. --- CHANGELOG.md | 5 +++ internal/agent/agent.go | 9 +++-- internal/gitlab/client.go | 72 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 81 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9bfa72..86c5236 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ Tag naming: `0.y.z` (no `v` prefix). Align `cmd/version.go` and `config/agent.ex ## [Unreleased] +### Changed + +- `gitlab.create_branch`, `gitlab.commit_files`, and `gitlab.create_mr` honor `project_path` in the skill payload (fallback to agent config when omitted). +- `gitlab.commit_files`: when an action includes `append_content`, the agent loads the file at `file_path` on the target branch, appends the block, and commits the merged body (avoids replacing the file with RAG-assembled snapshots). + ## [0.1.0] - 2026-05-26 ### Added diff --git a/internal/agent/agent.go b/internal/agent/agent.go index ffdba53..f40f950 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -118,12 +118,13 @@ func (a *Agent) execute(skill string, payload map[string]interface{}, _ map[stri switch skill { case "gitlab.create_branch": + projectPath := stringFromPayload(payload, "project_path", a.cfg.GitLab.ProjectPath) branch, _ := payload["branch"].(string) ref, _ := payload["ref"].(string) if ref == "" { ref = a.cfg.GitLab.TargetBranch } - if err := a.gitlab.CreateBranch(a.cfg.GitLab.ProjectPath, branch, ref); err != nil { + if err := a.gitlab.CreateBranch(projectPath, branch, ref); err != nil { return nil, err } return skillresult.Success(map[string]interface{}{"branch": branch}), nil @@ -138,7 +139,8 @@ func (a *Agent) execute(skill string, payload map[string]interface{}, _ map[stri normalized = append(normalized, m) } } - if err := a.gitlab.CommitFiles(a.cfg.GitLab.ProjectPath, branch, message, normalized); err != nil { + projectPath := stringFromPayload(payload, "project_path", a.cfg.GitLab.ProjectPath) + if err := a.gitlab.CommitFiles(projectPath, branch, message, normalized); err != nil { return nil, err } return skillresult.Success(map[string]interface{}{}), nil @@ -151,7 +153,8 @@ func (a *Agent) execute(skill string, payload map[string]interface{}, _ map[stri } title, _ := payload["title"].(string) description, _ := payload["description"].(string) - url, err := a.gitlab.CreateMergeRequest(a.cfg.GitLab.ProjectPath, source, target, title, description) + projectPath := stringFromPayload(payload, "project_path", a.cfg.GitLab.ProjectPath) + url, err := a.gitlab.CreateMergeRequest(projectPath, source, target, title, description) if err != nil { return nil, err } diff --git a/internal/gitlab/client.go b/internal/gitlab/client.go index a146003..92f7a25 100644 --- a/internal/gitlab/client.go +++ b/internal/gitlab/client.go @@ -2,6 +2,7 @@ package gitlab import ( "bytes" + "encoding/base64" "encoding/json" "fmt" "io" @@ -26,7 +27,14 @@ type APIError struct { } func (e *APIError) Error() string { - return fmt.Sprintf("gitlab API error status=%d", e.StatusCode) + body := strings.TrimSpace(e.Body) + if body == "" { + return fmt.Sprintf("gitlab API error status=%d", e.StatusCode) + } + if len(body) > 500 { + body = body[:500] + "..." + } + return fmt.Sprintf("gitlab API error status=%d: %s", e.StatusCode, body) } func New(baseURL, token, apiVersion string) *Client { @@ -72,15 +80,75 @@ func (c *Client) CreateBranch(projectPath, branch, ref string) error { return c.post(fmt.Sprintf("/projects/%s/repository/branches", c.projectID(projectPath)), body, nil) } +// GetRepositoryFile returns the decoded text of a file at ref (branch, tag, or commit SHA). +func (c *Client) GetRepositoryFile(projectPath, filePath, ref string) (string, error) { + resp := map[string]interface{}{} + path := fmt.Sprintf( + "/projects/%s/repository/files/%s?ref=%s", + c.projectID(projectPath), + url.PathEscape(strings.TrimPrefix(filePath, "/")), + url.QueryEscape(ref), + ) + if err := c.get(path, &resp); err != nil { + return "", err + } + encoding, _ := resp["encoding"].(string) + content, _ := resp["content"].(string) + if content == "" { + return "", fmt.Errorf("repository file %q at ref %q is empty", filePath, ref) + } + if encoding == "base64" { + raw, err := base64.StdEncoding.DecodeString(content) + if err != nil { + return "", fmt.Errorf("decode repository file %q: %w", filePath, err) + } + return string(raw), nil + } + return content, nil +} + func (c *Client) CommitFiles(projectPath, branch, message string, actions []map[string]interface{}) error { + resolved := make([]map[string]interface{}, 0, len(actions)) + for _, action := range actions { + merged, err := c.resolveCommitAction(projectPath, branch, action) + if err != nil { + return err + } + resolved = append(resolved, merged) + } body := map[string]interface{}{ "branch": branch, "commit_message": message, - "actions": actions, + "actions": resolved, } return c.post(fmt.Sprintf("/projects/%s/repository/commits", c.projectID(projectPath)), body, nil) } +func (c *Client) resolveCommitAction(projectPath, branch string, action map[string]interface{}) (map[string]interface{}, error) { + out := make(map[string]interface{}, len(action)) + for k, v := range action { + out[k] = v + } + appendText, _ := out["append_content"].(string) + appendText = strings.TrimSpace(appendText) + if appendText == "" { + delete(out, "append_content") + return out, nil + } + act, _ := out["action"].(string) + filePath, _ := out["file_path"].(string) + if act != "update" || strings.TrimSpace(filePath) == "" { + return nil, fmt.Errorf("append_content requires action update and file_path") + } + existing, err := c.GetRepositoryFile(projectPath, filePath, branch) + if err != nil { + return nil, err + } + out["content"] = strings.TrimRight(existing, "\n") + "\n\n" + appendText + "\n" + delete(out, "append_content") + return out, nil +} + func (c *Client) CreateMergeRequest(projectPath, source, target, title, description string) (string, error) { resp := map[string]interface{}{} body := map[string]interface{}{