Documentation
¶
Overview ¶
Package issuemanager provides a reconciler-style abstraction for managing GitHub Issues based on desired state. It discovers current state, compares it to desired state, and performs the necessary create/update/close operations to align reality with intent.
Reconciliation Model ¶
IssueManager follows a declarative reconciliation pattern:
Discover Current State: Query GitHub for existing issues using labels to identify issues managed by this reconciler instance
Extract Embedded Data: Read structured data embedded in issue bodies to understand the current state each issue represents
Compare with Desired State: Match existing issues to desired states using the Equal method defined on the data type
Reconcile Differences: - Create new issues for desired states with no existing issue - Update existing issues if their embedded data differs from desired - Close existing issues that no longer have a corresponding desired state
This makes it ideal for reconcilers that need to maintain a set of GitHub issues reflecting some external state (e.g., scan results, configuration drift, policy violations).
Session-Based Discovery ¶
Each reconciliation begins with a Session that discovers current state:
- NewSession queries GitHub for all open issues matching the identity label
- Each issue's body is parsed to extract embedded structured data
- The Session maintains a map of existing issues keyed by their data
- Issues with skip labels (skip:{identity}) are preserved during reconciliation
The Session provides a snapshot of current state for the reconciliation cycle.
Desired State Reconciliation ¶
The reconciler declares desired state as a slice of data objects:
- Reconcile accepts the desired states and performs a complete reconciliation
- Each data object's Equal method determines if an existing issue corresponds to it
- For matches, issues are updated only if the embedded data changed
- For non-matches, new issues are created
- Issues not in the desired set are automatically closed
- Issues with skip labels are preserved throughout all phases
This ensures GitHub issues always reflect the latest desired state.
Generic Type Parameter ¶
IssueManager is generic over type T, which must implement the Comparable[T] interface. This interface requires an Equal(T) bool method that determines when two instances represent the same logical issue.
The type T represents the structured data embedded in each issue. This data:
- Populates Go templates for issue titles and bodies
- Is embedded as JSON in HTML comments within the issue body
- Determines whether an issue needs updating (by comparing embedded vs desired)
- Enables matching between existing and desired issues via the Equal method
The type T must be JSON-serializable and contain the fields needed to identify and describe the issue's purpose. The Equal method typically compares identifying fields (like IDs) rather than all fields.
Identity Length Limit ¶
The identity parameter must not exceed 20 characters (maxIdentityLength). This ensures that labels constructed as "identity:path" stay within GitHub's 50 character label limit (maxGitHubLabelLength). When the combined length exceeds 50, the path is replaced with a truncated SHA256 hash.
Usage Example ¶
Create an IssueManager with templates for title and body:
type IssueData struct {
ID string
Status string
Priority string
}
// Equal implements the Comparable interface for IssueData.
// Issues are considered the same if they have the same ID.
func (d IssueData) Equal(other IssueData) bool {
return d.ID == other.ID
}
titleTmpl := template.Must(template.New("title").Parse("Issue {{.ID}}"))
bodyTmpl := template.Must(template.New("body").Parse("Status: {{.Status}}\nPriority: {{.Priority}}"))
im := issuemanager.New[IssueData]("my-reconciler", titleTmpl, bodyTmpl)
Optionally add label templates to generate dynamic labels from data:
labelTmpl := template.Must(template.New("priority").Parse("priority:{{.Priority}}"))
im := issuemanager.New[IssueData]("my-reconciler", titleTmpl, bodyTmpl,
issuemanager.WithLabelTemplates(labelTmpl),
issuemanager.WithMaxDesiredIssuesPerPath(1), // default, increase with caution
)
Start a reconciliation session to discover current state:
session, err := im.NewSession(ctx, ghClient, "owner/repo")
if err != nil {
return err
}
Reconcile to desired state with a single call. This performs create, update, and close operations atomically. Issues with skip labels are automatically preserved:
desiredStates := []*IssueData{
{ID: "001", Status: "active", Priority: "high"},
{ID: "002", Status: "pending", Priority: "medium"},
}
urls, err := session.Reconcile(ctx, desiredStates, []string{"automated"}, "No longer relevant")
if err != nil {
return err
}
This ensures exactly the desired set of issues exists and is up-to-date.
Example ¶
package main
import (
"context"
"text/template"
"chainguard.dev/driftlessaf/reconcilers/githubreconciler"
"chainguard.dev/driftlessaf/reconcilers/githubreconciler/issuemanager"
"github.com/google/go-github/v75/github"
)
type ExampleData struct {
Foo string
Bar string
Baz string
}
// Equal implements the Comparable interface for ExampleData.
// It compares Foo and Bar to determine if two instances represent the same issue.
func (e ExampleData) Equal(other ExampleData) bool {
return e.Foo == other.Foo && e.Bar == other.Bar
}
func main() {
// Parse templates once at initialization
titleTmpl := template.Must(template.New("title").Parse(`Issue {{.Foo}}: {{.Baz}}`))
bodyTmpl := template.Must(template.New("body").Parse(`This is issue **{{.Foo}}** for {{.Bar}}
**Status**: {{.Baz}}
Additional details here.`))
// Optional: Define label templates to generate dynamic labels from issue data
labelTmpl1 := template.Must(template.New("label1").Parse(`status:{{.Baz}}`))
labelTmpl2 := template.Must(template.New("label2").Parse(`category:{{.Bar}}`))
// Create manager once per identity with label templates
im, err := issuemanager.New[ExampleData]("example-manager", titleTmpl, bodyTmpl,
issuemanager.WithLabelTemplates[ExampleData](labelTmpl1, labelTmpl2),
)
if err != nil {
// handle error
return
}
// In your reconciler, create a session per resource
ctx := context.Background()
var ghClient *github.Client // your GitHub client
var res *githubreconciler.Resource
session, err := im.NewSession(ctx, ghClient, res)
if err != nil {
// handle error
return
}
// Define desired issues with data
// Note: Issues with skip labels (skip:example-manager) will be automatically preserved
desired := []*ExampleData{{
Foo: "foo",
Bar: "bar",
Baz: "baz",
}, {
Foo: "bar",
Bar: "baz",
Baz: "foo",
}}
// Reconcile performs a complete reconciliation: create, update, and close operations
// Matching is done using the Equal method
_, err = session.Reconcile(ctx, desired, []string{"example", "automated"}, "Issue no longer relevant")
if err != nil {
// handle error
return
}
}
Index ¶
Examples ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type Comparable ¶
Comparable is the interface that types must implement to be used with IssueManager. The Equal method determines if two instances represent the same issue based on identity fields (e.g., ID, unique combination of fields, etc.).
type IM ¶
type IM[T Comparable[T]] struct { // contains filtered or unexported fields }
IM manages the lifecycle of GitHub Issues for a specific identity. It uses Go templates to generate issue titles and bodies from generic data of type T. IssueManager can handle multiple issues per path. T must implement the Comparable interface to enable matching between existing and desired issues.
func New ¶
func New[T Comparable[T]](identity string, titleTemplate *template.Template, bodyTemplate *template.Template, opts ...Option[T]) (*IM[T], error)
New creates a new IM with the given identity and templates. The templates are executed with data of type T when creating or updating issues. Returns an error if titleTemplate or bodyTemplate is nil. Returns an error if identity exceeds maxIdentityLength (20 characters). This limit ensures labels (identity:path) stay within GitHub's maxGitHubLabelLength (50 characters). T must implement the Comparable interface to enable matching between existing and desired issues.
func (*IM[T]) NewSession ¶
func (im *IM[T]) NewSession( ctx context.Context, client *github.Client, res *githubreconciler.Resource, ) (*IssueSession[T], error)
NewSession creates a new IssueSession for the given resource. It validates that the resource is a Path type and queries for any existing issues with a label matching {identity}:{path}.
type IssueSession ¶
type IssueSession[T Comparable[T]] struct { // contains filtered or unexported fields }
IssueSession represents work on multiple issues for a specific resource path. Unlike Session in changemanager which handles a single PR, IssueSession manages multiple issues. T must implement the Comparable interface to enable matching between existing and desired issues.
func (*IssueSession[T]) Reconcile ¶
func (s *IssueSession[T]) Reconcile( ctx context.Context, desired []*T, extralabels []string, closeMessage string, ) ([]string, error)
Reconcile reconciles the issue state with the desired state by creating, updating, and closing issues. It performs a complete reconciliation: - Creates new issues for desired states without matching existing issues - Updates existing issues that match desired states (if content changed) - Closes existing issues that don't match any desired state Issues with the skip label are preserved and not modified in any phase. The pathLabel is automatically added to the provided labels. Returns a slice of issue URLs in the same order as the input data.
type Option ¶
type Option[T Comparable[T]] func(*IM[T])
Option configures an IM (IssueManager).
func WithLabelTemplates ¶
func WithLabelTemplates[T Comparable[T]](templates ...*template.Template) Option[T]
WithLabelTemplates sets the label templates for generating dynamic labels from issue data.
func WithMaxDesiredIssuesPerPath ¶
func WithMaxDesiredIssuesPerPath[T Comparable[T]](limit int) Option[T]
WithMaxDesiredIssuesPerPath sets the maximum number of desired issues allowed per path. Default is 1. WARNING: High values can cause GitHub API rate limit issues. The default of 1 is strongly recommended. Only increase if you understand the rate limit implications.
func WithOwner ¶
func WithOwner[T Comparable[T]](owner string) Option[T]
WithOwner overrides the GitHub owner (org or user) from the resource. When set, all issue operations will use this owner instead of the resource's owner.
func WithRepo ¶
func WithRepo[T Comparable[T]](repo string) Option[T]
WithRepo overrides the GitHub repository from the resource. When set, all issue operations will use this repo instead of the resource's repo.