sync

package
v0.18.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 26, 2026 License: Apache-2.0 Imports: 26 Imported by: 0

Documentation

Overview

Package sync fills the mirror from the configured sources (Jira, and Confluence when enabled). It only ever writes to the mirror, never to Jira or Confluence, and the rules it implements are in specs/000-product/contracts/sync.md.

Index

Constants

View Source
const (
	KindJira       = "jira"
	KindConfluence = "confluence"
	KindLinear     = "linear"
)

Source kinds — the Kind column of sources / sync_state rows, one value per connector. Single owner (GDK-619): every writer of the column and every comparison against it goes through these, never a bare literal.

View Source
const (
	PhaseIssues    = "issues"
	PhaseDocuments = "documents"
	PhaseIdle      = ""
)

Phase source names for Options.Phase. Callers report what the mirror is fetching; Progress carries how much. Keep these string values stable — the web client keys UI copy on them.

View Source
const ConfluenceSourceID = "confluence"

ConfluenceSourceID is the slug the wiki connector owns in sources / sync_state.

View Source
const LinearSourceID = "linear"

LinearSourceID is the slug the Linear connector owns in sources / sync_state. Mirrored item ids are "linear:<uuid>" — no standalone namespace variant: issuetap has no Linear surface, and Linear ids are uuids that cannot collide with anything numeric (the GDK-241 rationale does not apply).

View Source
const SourceID = "jira"

SourceID is the slug the Jira connector owns in `sources` and `sync_state`.

Variables

View Source
var ErrFrozen = errors.New("workspace is frozen: no sync into this mirror (config \"frozen\": true)")

ErrFrozen means this workspace refuses pulls from origin (config "frozen").

View Source
var ErrMirrorStale = errors.New("sync: mirror stale")

ErrMirrorStale is the cause of a write-through re-read failure: the origin already accepted the write; only the mirror did not refresh. Classify with errors.Is(err, ErrMirrorStale). Error() of a wrapped value is the underlying re-read sentence verbatim — the same shape as origin.unsupportedError (GDK-685 / GDK-740). The sentinel's own text never prefixes that sentence, so SQLITE_BUSY stays recoverable via errors.As / Unwrap.

View Source
var ErrNotFound = errors.New("sync: issue not found upstream")

ErrNotFound means the upstream answered but no issue/page with that key came back: it is outside the credential's permissions, or it was just deleted.

View Source
var WatchRestartPause = 30 * time.Second

WatchRestartPause is how long WatchLoop idles after a return (fatal auth, unexpected error) before re-entering. Frozen workspaces skip inside Watch itself (GDK-541). Tests may shrink this.

Functions

func DevLinksFromPRs added in v0.17.0

func DevLinksFromPRs(prs []jira.DevPR) store.DevLinksUpdate

DevLinksFromPRs maps dev-status pull requests onto a successful origin answer. Shared with `gadak dev link`'s post-write refresh so both paths store the same shape. A fetch error never produces this value. Author / actor / branch ride along when the origin serves them (GDK-589): Cloud's panel carries author{name} and source{branch}, issuetap adds the actor it stamped at write time; a payload without them stores empty strings.

func FlushAPIUsage added in v0.15.0

func FlushAPIUsage(ctx context.Context, db *store.DB, c usageTaker, logf func(string, ...any))

FlushAPIUsage takes the client's process-local counters and accumulates them into api_usage for the current UTC day. Jira and Confluence clients both satisfy usageTaker. One-shot sync, each Watch cycle, and `gadak api` go through this function so CLI and sync share one flush policy: a failure is logged and swallowed. api_usage is day-keyed with no source column — both connectors accumulate into the same daily row (schema contract).

func IdentityFromConfig added in v0.17.0

func IdentityFromConfig(cfg *config.Config) jql.Identity

IdentityFromConfig is the JQL identity (email / account id) cfg carries — the "me" of currentUser() when a JQL filter is compiled. Single owner (GDK-619): filter import and `gadak views save` both read it.

func IsRejectedCredential added in v0.14.2

func IsRejectedCredential(err error) bool

IsRejectedCredential reports whether err is a dead credential from any source. Transport errors (500, timeout, DNS) must stay false.

Owner of the detection rule: atlhttp.ErrAuth (what Do returns on 401/403) or any error implementing atlhttp.RejectedCredential. A third source that uses atlhttp.Do is covered without a branch here. A source that is not built on atlhttp still implements the method. Exported for the server's sync progress document — every surface reads this one rule as a code instead of matching error prose.

func MirrorStale added in v0.17.0

func MirrorStale(err error) error

MirrorStale wraps a re-read failure so callers classify with errors.Is. nil stays nil; an error already in the class is returned unchanged.

func RefreshIssue added in v0.17.0

func RefreshIssue(ctx context.Context, cfg *config.Config, db *store.DB, key, src string) error

RefreshIssue is the single owner of the write-through tail (CLI, including batch and create, and REST mutate / resync / attach). Both surfaces call RefreshIssue so the src routing cannot exist on one path and not the other: LinearSourceID goes to origin.Linear + SyncLinearIssue; every other src, including empty, goes to SyncIssue with default Options.

A failure is wrapped with ErrMirrorStale so every surface classifies the same class with errors.Is. Error() stays the inner re-read sentence verbatim (SQLITE_BUSY remains recoverable). handleResync still maps the wrapped error through failJira — that path is re-read only, not a landed write. CLI/REST write-through sites name the class; this function does not log or emit surface copy.

func RefreshPage added in v0.17.0

func RefreshPage(ctx context.Context, cfg *config.Config, db *store.DB, id string) error

RefreshPage is the wiki write-through tail: SyncPage, then ErrMirrorStale. handlePageResync stays on SyncPage + failJira (re-read only).

func SyncIssue

func SyncIssue(ctx context.Context, cfg *config.Config, db *store.DB, key string, opts Options) error

SyncIssue re-reads one issue and writes it to the mirror. RefreshIssue is the write-through tail that routes here (or to SyncLinearIssue); CLI/REST must not branch on src at the call site. Ending here is what makes a row that came back from a write identical to one a scheduled sync produced — same field mapping, same derived fields, no second code path to keep in step.

func SyncLinearIssue added in v0.16.1

func SyncLinearIssue(ctx context.Context, db *store.DB, c *linear.Client, key string) error

SyncLinearIssue re-reads one Linear issue and commits the row, the Linear counterpart of SyncIssue. RefreshIssue is the write-through tail that routes here; CLI/REST must not branch on src at the call site. Force is on for the same reason as the Jira path — the caller just wrote, so an unchanged updatedAt must not skip the refresh.

func SyncPage

func SyncPage(ctx context.Context, cfg *config.Config, db *store.DB, id string) error

SyncPage re-reads one Confluence page and writes it to the mirror. Used when the desktop app closes an in-app browser tab after editing a wiki page: the rewrite moves synced_at and bumps sync_state.version so the client's next pages list / detail fetch sees the edit.

Unlike a full Confluence pass (commitBatch in confluence.go), this must not call RecordSync. A single page's lastModified must not become the source watermark — that would jump incremental sync into the future and skip every page modified in between. UpsertPages alone is enough: it rewrites the row and bumps version without advancing the watermark.

func Watch

func Watch(ctx context.Context, cfg *config.Config, db *store.DB, opts Options) error

Watch runs incremental sync on an interval and reconcile on a longer one. A transport failure is logged and retried on the next tick; a rejected credential (IsRejectedCredential — atlhttp.ErrAuth or any error implementing atlhttp.RejectedCredential) records last_error, logs once, and stops retrying that source, because every further request would only burn rate budget. Jira is fatal (the loop ends). Confluence is not: Jira mirroring keeps going when only the wiki side is rejected. `gadak doctor` (sync.<id>.last_error) and sync_health read that last_error. `gadak status --json` last_error is the Jira row only. A later one-shot Run / RunConfluence with a new token still clears it. After each successful Jira cycle, new personal-feed events may produce one OS desktop notification (see notifyAfterSync); notification failures never stop the loop.

When opts.Reload is set, each cycle re-reads config so a settings edit (projects, Confluence, intervals) takes effect without restarting the process.

func WatchLoop added in v0.17.0

func WatchLoop(ctx context.Context, cfg *config.Config, db *store.DB, opts Options)

WatchLoop re-enters Watch until ctx is done. A rejected credential still ends Watch (no hot-loop on 401); this wait is what spaces retries until Reload sees a new credential (GDK-541).

Stop and Reload-failure messages go to opts.Log when set, otherwise to log.Printf — MCP stdout is the protocol, so those lines must use mcp.Logf via opts.Log, and workspace mounts want a profile prefix on the same hook.

Types

type OSNotifier

type OSNotifier struct{}

OSNotifier uses the platform's desktop-notification command. darwin: osascript display notification; linux: notify-send; others: unsupported.

func (OSNotifier) Notify

func (OSNotifier) Notify(title, body string) error

Notify implements notifier.

func (OSNotifier) Supported added in v0.17.0

func (OSNotifier) Supported() bool

Supported reports whether this process can fire a real OS notification.

type Options

type Options struct {
	Full      bool
	Reconcile bool
	// Log, when set, receives one line per committed page and per pass.
	Log func(string)
	// Progress, when set, is called once per committed page with the running
	// totals. It exists so a caller can report progress without parsing Log.
	Progress func(fetched, changed int)
	// Phase, when set, is called with the connector a pass is about to run
	// ("issues", "documents") and with "" when the cycle has nothing in flight.
	// It exists so a caller can report *what* is being fetched; Progress carries
	// how much. Watch calls it around each source; one-shot callers set it too.
	Phase func(source string)
	// Client is for tests and for a server that wants to share one; nil builds
	// one from cfg.
	Client *jira.Client
	// ConfluenceClient is for tests; nil builds one from cfg when Confluence is configured.
	ConfluenceClient *confluence.Client
	// LinearClient is for tests; nil builds one from cfg when Linear is configured.
	LinearClient *linear.Client

	// Reload re-reads the config at the top of each watch cycle. Nil keeps the
	// config Watch was called with. A reload error is logged and the previous
	// config stays in use: a momentarily unreadable file must not stop the mirror.
	Reload func() (*config.Config, error)
	// Tick, when > 0, is the Watch interval. The zero value derives from
	// cfg.EffectiveSyncIntervalSec() (an integer number of seconds), which is
	// the production path. Tests set a sub-second Tick so they do not sit on
	// that 1s floor.
	Tick time.Duration
	// contains filtered or unexported fields
}

Options tunes one Run or Watch cycle. A nil Client or ConfluenceClient is built from cfg; a nil notifier uses OSNotifier and never aborts the loop; a Reload error keeps the previous config so a momentarily unreadable file cannot stop the mirror.

type Result

type Result struct {
	Full      bool
	Fetched   int
	Changed   int
	Deleted   int
	Watermark string
	// PageBodies and PageSkips are the Confluence pass's body-read tally: how
	// many page bodies this pass went to the source for (a hit that turned out
	// to be deleted still counts — the request was spent), and how many search
	// hits pageFetchGate answered from the mirror instead. They are the answer
	// to "how many bodies did that tick fetch?" — a quiet tick over an
	// unchanged corpus must report PageBodies 0. Jira leaves both at 0.
	PageBodies int
	PageSkips  int
}

Result is the tally of one source pass (Run or RunConfluence). Fetched/Changed/Deleted count what this pass touched. Full is true when the pass had no watermark to increment from, or the caller asked for one. Watermark is the newest upstream timestamp recorded on success.

func Run

func Run(ctx context.Context, cfg *config.Config, db *store.DB, opts Options) (Result, error)

Run does one sync pass: full or incremental, plus a reconcile pass when asked for or after a full sync.

A failure leaves the pages already committed in place and does not advance the watermark, so the next run re-reads from the last known-good point (contracts/sync.md invariants 2 and 3).

An empty cfg.Projects means no project filter: the account's full visible issue set is the scope (one Search, not one per project).

func RunConfluence

func RunConfluence(ctx context.Context, cfg *config.Config, db *store.DB, opts Options) (Result, error)

RunConfluence does one Confluence mirror pass: full or incremental. Attachments stay out of scope. Version-history stamps (page_versions; never bodies) are collected when a mirrored page's current version number is not already stored. A failed history fetch is logged and does not fail the pass. Every successful pass prunes pages whose space is outside the current config/listing scope. Incremental floors are per-space (spaces.watermark); a failed space does not move its own watermark or any other space's. A floor selects candidates, it does not decide fetches: pageFetchGate does, and an incremental tick over an unchanged space reads zero page bodies.

A failure leaves already-committed batches in place.

func RunLinear added in v0.16.1

func RunLinear(ctx context.Context, cfg *config.Config, db *store.DB, opts Options) (Result, error)

RunLinear does one Linear mirror pass: full or incremental. Read-only by constitution — the client has no mutations. stateHistory / history are still not mirrored (status_changed_at / reopen_count stay NULL).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL