← repoman internals

internal/issuesync

import "git.griefed.de/griefed/repoman/internal/issuesync"
package issuesync // import "git.griefed.de/griefed/repoman/internal/issuesync"

Package issuesync is the issue hub: it imports issues + comments from source
platforms into the Forgejo hub and (in later phases) keeps them in sync both
ways. See docs/issue-hub.md for the design.

FUNCTIONS

func VerifyGitLabToken(headerToken, secret string) bool
    VerifyGitLabToken reports whether GitLab's X-Gitlab-Token header equals
    the configured secret. GitLab does not HMAC the body; it sends the secret
    verbatim, so this is a constant-time equality check. An empty secret fails
    closed.

func VerifyHMACSignature(body []byte, secret, sigHeader string) bool
    VerifyHMACSignature reports whether sigHeader is a valid HMAC-SHA256
    of body under secret. It covers Forgejo/Gitea (X-Forgejo-Signature /
    X-Gitea-Signature, raw hex) AND GitHub (X-Hub-Signature-256, "sha256=" +
    hex) — the optional "sha256=" prefix is stripped, so one function serves all
    three. An empty secret or header, or an unparseable signature, fails closed.


TYPES

type Engine struct {
	Forgejo *forgejo.Client
	Repo    *db.IssueSyncRepo
}
    Engine applies the bidirectional issue sync. It is fed each side's CURRENT
    content (from a webhook payload or the reconcile poll) and resolves it
    against the stored baselines. See docs/issue-hub.md.

func (e *Engine) PullSourceIssue(ctx context.Context, link *db.IssueLink,
	ext IssueContent, author string, emit func(string)) error
    PullSourceIssue pulls a source-side issue edit into the Forgejo hub.
    It is the source-webhook fast path (the mirror of PushForgejoIssue): it
    re-applies the hub copy's existing attribution header (fetched from Forgejo)
    to the new source body so the original author stays named, and writes
    title+body+state back to the hub. It skips when the source content already
    equals the last-synced baseline — which also breaks the repoman-write echo,
    since repoman's own write to the source advanced this baseline before the
    source webhook could arrive. author seeds the header only as a fallback when
    the hub copy has none.

func (e *Engine) PushForgejoIssue(ctx context.Context, sw source.IssueWriter, link *db.IssueLink,
	hub IssueContent, emit func(string)) error
    PushForgejoIssue propagates a hub-side issue edit out to one linked source.
    It is the Forgejo-webhook fast path: the trigger IS a hub edit, so it pushes
    Forgejo → source whenever the hub content differs from the last-synced
    baseline (the hub-wins rule — the reconcile poll does the full two-sided
    merge and conflict logging). The source receives the clean, header-stripped
    body; both baselines advance on success. sw writes the source side; emit may
    be nil.

func (e *Engine) SyncForgejoComment(ctx context.Context, sw source.IssueWriter, link *db.IssueLink,
	forgejoCommentID int64, hubBody string, emit func(string)) error
    SyncForgejoComment propagates a Forgejo-side comment to the source.
    An unmapped comment is a new hub comment → created on the source (body
    stripped of any header) and recorded; a mapped comment whose body changed
    is edited on the source. (A new hub comment is the maintainer's own text,
    so stripping is a no-op.)

func (e *Engine) SyncIssue(ctx context.Context, sw source.IssueWriter, link *db.IssueLink,
	hub, ext IssueContent, emit func(string)) error
    SyncIssue reconciles one mapped issue from both sides' current content via a
    3-way merge against the link's baselines:

      - only Forgejo changed → push it to the source (strip the header);
      - only the source changed → pull it into Forgejo (re-apply the header);
      - both changed → CONFLICT: the hub (Forgejo) wins, logged;
      - neither → nothing.

    After applying, both baselines are advanced to the synced content. sw writes
    the source side; emit may be nil.

func (e *Engine) SyncSourceComment(ctx context.Context, link *db.IssueLink,
	sourceCommentID, author, extBody string, emit func(string)) error
    SyncSourceComment propagates a source-side comment to the Forgejo hub.
    An unmapped comment is new → created in Forgejo with an attribution header
    naming author, and recorded; a mapped comment whose body changed is edited
    (header re-applied). The header is rebuilt deterministically from author +
    platform.

type ForgejoEvent struct {
	EventType   string // "issues" | "issue_comment"
	Action      string // "opened","edited","closed","reopened","deleted","created",…
	Owner       string
	Repo        string
	IssueNumber int64
	Issue       *IssueContent // set for "issues" events
	CommentID   int64         // set for "issue_comment" events
	CommentBody string
	Sender      string // the login that triggered the event (loop avoidance)
}
    ForgejoEvent is the normalised subset of a Forgejo issues/issue_comment
    webhook the engine needs. For an "issues" event Issue is set; for an
    "issue_comment" event CommentID/CommentBody are.

func ParseForgejoEvent(eventType string, body []byte) (*ForgejoEvent, error)
    ParseForgejoEvent decodes a Forgejo webhook body for the given event type
    (from the X-Forgejo-Event header — "issues" or "issue_comment"). It returns
    an error for an unsupported event type or a malformed/incomplete payload so
    the caller can 400 without touching the engine.

type ImportResult struct {
	Imported         int // issues created in Forgejo
	Skipped          int // issues already mapped (idempotent re-run)
	CommentsImported int
	Errors           int
}
    ImportResult counts what an import did.

type Importer struct {
	Forgejo *forgejo.Client
	Repo    *db.IssueSyncRepo
}
    Importer imports source issues+comments into the Forgejo hub, recording the
    mapping + per-side baselines the sync engine later merges against.

func (im *Importer) ImportRepo(ctx context.Context, src source.IssueClient, link RepoLink, emit func(string)) (ImportResult, error)
    ImportRepo imports every issue (and its comments) for one linked repo from
    src into the Forgejo hub, idempotently: an issue already mapped (keyed on
    the SOURCE side) is skipped, so re-running only brings across new issues.
    Each imported issue/comment is created bot-authored with an attribution
    header naming the original author, and recorded in the mapping with both
    baselines. Best-effort: a per-issue failure is counted and the rest proceed.
    emit may be nil.

type IssueContent struct {
	Title string
	Body  string
	State string
}
    IssueContent is one side's current issue content, the input to the merge.

type Reconciler struct {
	Forgejo      *forgejo.Client
	Repo         *db.IssueSyncRepo
	Engine       *Engine
	SourceClient SourceClientFactory
}
    Reconciler is the issue-hub safety net: a periodic pass that re-runs the
    3-way merge for every mapped issue, catching webhook deliveries that were
    dropped, failed, or arrived while repoman was down. It is the source of
    truth for correctness; webhooks are the low-latency fast path. It reads
    each side's current issues in BATCH (one ListIssues per Forgejo repo and per
    source repo, cached within a pass) rather than per-issue, so a repo with N
    links costs O(1) list calls, not O(N). See docs/issue-hub.md.

func (rec *Reconciler) ReconcileAll(ctx context.Context, emit func(string)) (int, error)
    ReconcileAll sweeps every issue link, running SyncIssue for each against
    both sides' current content. Unchanged issues are no-ops (hash == baseline),
    so a steady state is cheap. Returns the number of links whose merge ran
    without error. Best-effort: a per-link/-repo failure is logged via emit and
    skipped.

type RepoLink struct {
	ForgejoOwner    string
	ForgejoRepo     string
	SourcePlatform  string
	SourceNamespace string
	SourceRepo      string
}
    RepoLink identifies a single Forgejo repo and the source repo it mirrors,
    the unit ImportRepo works on.

type SourceClientFactory func(platform, namespace string) (source.Client, error)
    SourceClientFactory builds a source.Client for a link's platform +
    namespace, resolving the token/instance-URL from the owning config. Supplied
    by the wiring layer (serve.go), which alone knows the configs and global
    settings.

type SourceEvent struct {
	Platform    string
	Kind        string // "issue" | "comment"
	Action      string
	Namespace   string
	Repo        string
	IssueNumber int64
	Issue       *IssueContent
	CommentID   string
	CommentBody string
	Author      string
}
    SourceEvent is the normalised subset of a source-platform issue/comment
    webhook the engine needs, across github/gitlab/gitea/codeberg. Kind is
    "issue" or "comment". For an issue event Issue is set; for a comment event
    CommentID/CommentBody are. Author is the actor login (attribution + an extra
    loop-avoidance signal). CommentID is a string because GitLab note ids are
    large.

func ParseSourceEvent(platform, eventType string, body []byte) (*SourceEvent, error)
    ParseSourceEvent decodes a source webhook into a SourceEvent, dispatching on
    platform (and, for github/gitea, the X-*-Event header). Gitea/Codeberg send
    a Forgejo-compatible payload, so they reuse that decoder. Returns an error
    for an unsupported platform/event or a malformed/incomplete payload.