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.