Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion internal/forge/fake.go
Original file line number Diff line number Diff line change
Expand Up @@ -1382,7 +1382,7 @@ func (f *FakeClient) UpdateIssueComment(_ context.Context, owner, repo string, c
}
}
}
return nil
return fmt.Errorf("%w: comment %d", ErrNotFound, commentID)
Comment thread
ralphbean marked this conversation as resolved.
}

func (f *FakeClient) DeleteIssueComment(_ context.Context, _, _ string, commentID int) error {
Expand Down
151 changes: 151 additions & 0 deletions internal/tracker/forge_client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
package tracker

import (
"context"
"fmt"
"strconv"
"strings"

"github.com/fullsend-ai/fullsend/internal/forge"
)

// ForgeClient adapts a forge.Client to the tracker.Client interface. It
// works for both the GitHub and GitLab forge.Client implementations, since
// forge.Client already abstracts over the two — this adapter only needs to
// split the tracker's single "project" string back into the owner/repo
// pair that forge.Client expects, and convert forge.IssueComment's numeric
// ID to the string form tracker.Comment uses.
type ForgeClient struct {
forge forge.Client
}

// NewForgeClient returns a tracker.Client backed by fc.
func NewForgeClient(fc forge.Client) *ForgeClient {
return &ForgeClient{forge: fc}
}

// GetIssue implements Client by splitting project into owner/repo for the
// underlying forge call.
func (c *ForgeClient) GetIssue(ctx context.Context, project string, number int) (*Issue, error) {
owner, repo, err := splitProject(project)
if err != nil {
return nil, err
}
issue, err := c.forge.GetIssue(ctx, owner, repo, number)
if err != nil {
return nil, wrapNotFound(err)
}
return &Issue{
Number: issue.Number,
Title: issue.Title,
Body: Body(issue.Body),
URL: issue.URL,
Labels: issue.Labels,
}, nil
}

// ListComments implements Client by splitting project into owner/repo for
// the underlying forge call.
func (c *ForgeClient) ListComments(ctx context.Context, project string, number int) ([]Comment, error) {
owner, repo, err := splitProject(project)
if err != nil {
return nil, err
}
comments, err := c.forge.ListIssueComments(ctx, owner, repo, number)
if err != nil {
return nil, wrapNotFound(err)
}
result := make([]Comment, len(comments))
for i, fc := range comments {
result[i] = fromForgeComment(fc)
}
return result, nil
}

// CreateComment implements Client by splitting project into owner/repo for
// the underlying forge call.
func (c *ForgeClient) CreateComment(ctx context.Context, project string, number int, body Body) (*Comment, error) {
owner, repo, err := splitProject(project)
if err != nil {
return nil, err
}
comment, err := c.forge.CreateIssueComment(ctx, owner, repo, number, string(body))
if err != nil {
return nil, wrapNotFound(err)
}
result := fromForgeComment(*comment)
return &result, nil
}

Comment thread
ralphbean marked this conversation as resolved.
// UpdateComment implements Client. number is unused here: forge.Client's
// UpdateIssueComment takes only a comment ID, which is sufficient for
// GitHub (comment IDs are globally unique within the repo). GitLab
// actually needs the issue/MR IID to address a note directly — see
// gitlab.LiveClient.updateOrDeleteNote — but forge.Client's
// UpdateIssueComment doesn't expose one, so the GitLab path still falls
// back to that method's documented scan. Jira needs the issue key for an
// unrelated reason: it isn't a forge.Client implementation at all.
func (c *ForgeClient) UpdateComment(ctx context.Context, project string, number int, commentID string, body Body) error {
owner, repo, err := splitProject(project)
if err != nil {
return err
}
id, err := strconv.Atoi(commentID)
if err != nil {
return fmt.Errorf("tracker: comment ID %q is not numeric: %w", commentID, err)
}
return wrapNotFound(c.forge.UpdateIssueComment(ctx, owner, repo, id, string(body)))
}

// wrapNotFound translates a forge.ErrNotFound-satisfying error into one
// that also satisfies tracker.ErrNotFound, so ForgeClient upholds the
// Client interface's NotFound contract without leaking forge as part of
// tracker.Client's error surface. Non-NotFound errors, including nil,
// pass through unchanged.
func wrapNotFound(err error) error {
if !forge.IsNotFound(err) {
return err
}
return &notFoundError{err: err}
}

// notFoundError makes a forge error also satisfy tracker.IsNotFound
// without repeating "not found" in its message — the wrapped forge error's
// text already says that.
type notFoundError struct {
err error
}

func (e *notFoundError) Error() string { return e.err.Error() }
func (e *notFoundError) Unwrap() []error { return []error{ErrNotFound, e.err} }

func fromForgeComment(c forge.IssueComment) Comment {
return Comment{
ID: strconv.Itoa(c.ID),
HTMLURL: c.HTMLURL,
Body: Body(c.Body),
Author: c.Author,
CreatedAt: c.CreatedAt,
}
}

// splitProject splits "group/subgroup/project" into owner="group/subgroup"
// and repo="project". GitHub projects are always single-level
// ("owner/repo"), which this also handles correctly since there's only one
// "/". GitLab projects may be nested under subgroups, hence splitting on
// the last "/" rather than the first.
//
// It returns an error if project doesn't split into a non-empty owner and
// a non-empty repo, so callers don't silently forward malformed values
// (e.g. missing owner or repo) into forge.Client calls that require both.
func splitProject(project string) (owner, repo string, err error) {
Comment thread
ralphbean marked this conversation as resolved.
idx := strings.LastIndex(project, "/")
if idx < 0 {
return "", "", fmt.Errorf("tracker: invalid project %q: expected \"owner/repo\"", project)
}
owner, repo = project[:idx], project[idx+1:]
if owner == "" || repo == "" {
return "", "", fmt.Errorf("tracker: invalid project %q: owner and repo must both be non-empty", project)
}
return owner, repo, nil
}
87 changes: 87 additions & 0 deletions internal/tracker/tracker.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// Package tracker defines a narrow, forge-agnostic interface for reading
Comment thread
ralphbean marked this conversation as resolved.
Comment thread
ralphbean marked this conversation as resolved.
// and writing issue content (title, body, comments), keyed by
// (project string, number int) rather than the (owner, repo string, number
// int) shape used by forge.Client.
//
// forge.Client already covers this surface for GitHub and GitLab, but it
// stays scoped to git-hosting operations — Jira is explicitly not a forge
// (it has no branches, pull requests, or CI). Keying by a single project
// string lets a future Jira implementation use its natural project key
// (e.g. "PROJECT") instead of forcing an owner/repo split that Jira
// doesn't have; the issue number is passed separately, as with GitHub and
// GitLab.
//
// This package only defines the interface and thin adapters over
// forge.Client (see ForgeClient). Nothing calls tracker.Client yet.
package tracker

import (
"context"
"errors"
)

// ErrNotFound indicates a requested issue or comment was not found.
// Implementations of Client must return an error satisfying errors.Is(err,
// ErrNotFound) — checkable via IsNotFound — for missing resources, rather
// than requiring callers to reach into a specific tracker backend (e.g.
// forge.ErrNotFound) to detect this case.
var ErrNotFound = errors.New("not found")

// IsNotFound reports whether err indicates a requested issue or comment
// was not found.
func IsNotFound(err error) bool {
return errors.Is(err, ErrNotFound)
}

// Body is Markdown-formatted issue/comment text, as produced by GitHub and
// GitLab. Jira doesn't speak Markdown — its v3 API requires comment and
// description bodies in Atlassian Document Format (ADF) and rejects plain
// strings outright. A Jira Client implementation is responsible for
// converting Body to and from ADF; a naive pass-through (wrapping the raw
// Markdown string in a single ADF text node) doesn't just lose formatting,
// it actively corrupts content — Jira's plain-text rendering path
// interprets stray Markdown characters (e.g. braces in code samples) as
// wiki-markup and mangles the surrounding text.
type Body string

// Issue represents an issue's content, independent of the tracker backend.
type Issue struct {
Number int
Title string
Body Body
URL string
Labels []string
}

// Comment represents a comment on an issue.
//
// ID is a string for JSON round-tripping safety and to allow for
// non-numeric IDs from a possible future tracker, even though GitHub,
// GitLab, and Jira comment IDs are all numeric under the hood. Callers
// that need to update a comment pass the ID back verbatim via
// UpdateComment.
type Comment struct {
ID string
HTMLURL string
Body Body
Author string
CreatedAt string
}

// Client abstracts issue-content read/write operations across trackers
Comment thread
ralphbean marked this conversation as resolved.
// (GitHub, GitLab, and eventually Jira). Project identifies the issue's
// container: "owner/repo" for GitHub/GitLab, a Jira project key for Jira.
//
// Implementations must return an error satisfying IsNotFound when the
// requested issue or comment doesn't exist.
type Client interface {
// GetIssue returns the issue identified by project and number.
GetIssue(ctx context.Context, project string, number int) (*Issue, error)
// ListComments returns all comments on the issue identified by project and number.
ListComments(ctx context.Context, project string, number int) ([]Comment, error)
// CreateComment adds a new comment with the given body to the issue.
CreateComment(ctx context.Context, project string, number int, body Body) (*Comment, error)
// UpdateComment updates the body of commentID on the issue (project, number).
// number is included because Jira requires the issue key to update a comment.
UpdateComment(ctx context.Context, project string, number int, commentID string, body Body) error
}
Loading
Loading