Skip to content

Issue

An Issue CR is tatara's mirror of one issue on the forge (GitHub or GitLab). It is namespaced, carries a status subresource, and its short name is iss.

apiVersion: tatara.dev/v1alpha1
kind: Issue

The CR name is iss-<repositoryRef>-<number> (for example iss-tatara-operator-291). <repositoryRef> is the Repository CR name, which is already RFC-1123-safe - never the raw owner/repo slug.

The operator also indexes Issues by issueKey = <repositoryRef>#<number> for dedup lookups in the sweep and the QueuedEvent producer. That index is a direct field lookup, never a label selector: label values reject : and #, so a "natural key" encoded as a label would end up sha256-hashed back into an opaque digest.


IssueSpec

Field Type Required Description
repositoryRef string yes Name of the owning Repository CR
number int (min 1) yes The issue number on the forge
url string yes The issue's URL on the forge
projectRef string yes Name of the owning Project CR

IssueStatus

Field Type Description
title string Issue title, last synced
author string Issue author's forge login
body string (max 65536 chars) Issue body, last synced
createdAt *Time When the issue was opened on the forge
updatedAt *Time Last forge-side update timestamp
state open | closed SCM truth - mirrors the forge's own open/closed state
status new | approved | rejected | done The platform's decision state. Operator-owned; see the danger note below
labels []string (max 50) The issue's current labels, as synced from the forge
comments []Comment (max 200) The most recent comments held in the CR
commentCount int len(comments) + spilledComments - a scalar the Comments print column can render, since kubebuilder cannot count a list
spilledComments int Count of oldest comments evicted to tatara-memory by the byte-budget guard
spilledCommentsRefs []string (max 50) One tatara-memory track ID per spill batch. Accumulates - a single scalar ref would silently orphan every earlier batch on the second spill
approval *ApprovalEvidence Single-use evidence backing status: approved. nil means no verified approval
commentsRetainedFrom *Time The eviction watermark: the createdAt of the oldest comment still held in comments. The mirror sync ingests only comments newer than this, so an evicted comment is never re-fetched and re-spilled on the next sweep
pendingComments []PendingComment (max 20) Durable comment/reply intents. issue_write(action=comment\|edit\|close) writes here and returns; the Issue reconciler drains it to the forge. issue_write(action=create) is synchronous instead, because the agent needs the new issue number back
lastSyncedAt *Time When the mirror last synced against the forge
conditions []metav1.Condition Standard Kubernetes conditions

PendingComment has requestId (the client-supplied idempotency key), action (comment | reply), body (max 16384 chars), and inReplyTo (optional). The same shape backs MergeRequestStatus.pendingComments. See the review post for how the reconciler drains a pending intent without double-posting.

status is operator-owned, and only the operator writes it

Issue.status.status is operator-owned and webhook-driven only. No MCP tool writes it. No agent-reachable REST endpoint writes it. No cron or sweep path writes it. A label read never produces it. See Labels are write-only.

Ownership: an Issue is owned by 1..N Tasks, and exactly one carries controller: true - the Task responsible for it right now. That owner is what the Task print column renders. See Who owns what for the full rule, including how ownership moves during a refine fold.


Comment

Field Type Description
externalId string The provider's comment ID, always a string - GitHub uses an int64, GitLab a note ID, and the two disagree on width
author string Comment author's forge login
body string (max 8192 bytes) Comment body, truncated at ingest - see below
createdAt Time When the comment was posted
isBot bool true when author == Project.spec.scm.botLogin
truncated bool true when ingest cut body at 8192 bytes
path string File an inline review comment is anchored to. Empty for a plain comment
line int Line an inline review comment is anchored to. Zero when unset
inReplyTo string externalId of the review comment this one replies to
reviewRound int The review round this comment was posted in, when the operator authored it. Zero for every comment the operator did not author

isBot is the structural bot exclusion both the approval grammar and the sweep's pendingEvents enqueue filter rely on: it is set from Project.spec.scm.botLogin at ingest, not inferred from comment content.

The 8192-byte truncation is not conservative for its own sake. GitHub allows 65,536-character comment bodies: 25 max-size comments alone is 1.6 MB, over the etcd object ceiling on their own. A 64 KB comment is not prompt-useful anyway. When ingest truncates, the rendered bundle carries truncated="true" on the <comment> element, so the agent knows the text is partial and can pull the full body with scm_read(kind=comments).


ApprovalEvidence

Field Type Description
login string A verified maintainer login - never the bot
commentId string The Comment.externalId the implement agent cited, which the operator confirmed exists on this Issue
createdAt Time When the cited comment was posted
phrase string The matched form of the agent's cited quote: as submitted if that literal substring occurred in the comment body, or html-unescaped if only that form matched (the turn-0 bundle XML-escapes comment bodies, so a maintainer's let's ship it can render as let&apos;s ship it). Not necessarily byte-identical to what the agent submitted, and not a match against any configured wordlist
auto bool Set on the auto-approve carve-out path (Project.spec.autoApproveMaxSignificance above off). When true, login is the sentinel <tatara:auto> and commentId is empty. It is also what makes the grant SEVERITY-CAPPED: submit_outcome(action=submitted) refuses over-auto-approve-ceiling when the declared change_significance exceeds the project ceiling. Evidence with auto unset was cited by a maintainer and is never capped

ApprovalEvidence is single-use: a later approval must cite a comment that has not already been consumed, and a replayed commentId is refused. There is no requirement that the cited comment be the thread's most recent maintainer comment. Issue.status.approval being nil means no verified approval exists, and the operator fails closed - see the approval grammar for the full clause-by-clause rule this struct is evidence for.


Column Source
Task .metadata.ownerReferences[?(@.controller==true)].name
Repo .spec.repositoryRef
Num .spec.number
State .status.state
Status .status.status
Comments .status.commentCount
Age standard

The mirror is a working set, not an archive

Issue and MergeRequest CRs are tatara's mirror of the forge. scm_read(kind=issues|mr|comments) is served from this mirror and never touches the forge - so the steady-state agent read cost of the platform is zero forge requests, except scm_read(kind=ci).

The mirror is at most one sweep behind. Every scm_read response carries lastSyncedAt so an agent can see the staleness rather than assume freshness. There is deliberately no refresh=true escape hatch: adding one would hand every agent a forge-fanout button. The one path where staleness is dangerous - refine closing an issue - re-validates live immediately before each close.

The mirror is garbage-collected on delivery. It is not a permanent copy of your tracker.


See also

  • MergeRequest - the mirror's other half, and the merge/deploy path
  • Task - the CRD that owns Issues and drives them through the stage machine
  • Task stages - the stage machine status.status feeds into
  • Approval gates - the full comment-text approval grammar and why labels are write-only
  • Ownership - who owns an Issue, and how ownership moves