@@ -724,6 +724,98 @@ func batchDeleteRows(ctx context.Context, db *sql.DB, idQuery string, cutoffArg
724724 return totalDeleted , nil
725725}
726726
727+ // ClosePluginReceiptResult holds the results of closing plugin run receipts.
728+ type ClosePluginReceiptResult struct {
729+ Database string `json:"database"`
730+ Closed int `json:"closed"`
731+ DryRun bool `json:"dry_run,omitempty"`
732+ Anomalies []Anomaly `json:"anomalies,omitempty"`
733+ }
734+
735+ // ClosePluginReceipts closes open issues labeled "type:plugin-run" that are
736+ // older than maxAge. These are transient run receipts created by deacon dog
737+ // plugins; they should be closed shortly after creation since they exist only
738+ // for audit/cooldown-gate purposes. The standard AutoClose path requires 7 days
739+ // of staleness, which lets plugin receipts accumulate into the hundreds.
740+ func ClosePluginReceipts (db * sql.DB , dbName string , maxAge time.Duration , dryRun bool ) (* ClosePluginReceiptResult , error ) {
741+ ctx , cancel := context .WithTimeout (context .Background (), DefaultQueryTimeout )
742+ defer cancel ()
743+
744+ cutoff := time .Now ().UTC ().Add (- maxAge )
745+ result := & ClosePluginReceiptResult {Database : dbName , DryRun : dryRun }
746+
747+ // Find open issues with the "type:plugin-run" label older than maxAge.
748+ selectQuery := fmt .Sprintf (`
749+ SELECT i.id FROM ` + "`%s`" + `.issues i
750+ INNER JOIN ` + "`%s`" + `.labels l ON i.id = l.issue_id
751+ WHERE i.status IN ('open', 'in_progress')
752+ AND l.label = 'type:plugin-run'
753+ AND i.created_at < ?` , dbName , dbName )
754+
755+ rows , err := db .QueryContext (ctx , selectQuery , cutoff )
756+ if err != nil {
757+ if isTableNotFound (err ) {
758+ return result , nil
759+ }
760+ return nil , fmt .Errorf ("select plugin receipts: %w" , err )
761+ }
762+ var ids []string
763+ for rows .Next () {
764+ var id string
765+ if err := rows .Scan (& id ); err != nil {
766+ rows .Close ()
767+ return nil , fmt .Errorf ("scan plugin receipt id: %w" , err )
768+ }
769+ ids = append (ids , id )
770+ }
771+ rows .Close ()
772+
773+ result .Closed = len (ids )
774+ if len (ids ) == 0 || dryRun {
775+ return result , nil
776+ }
777+
778+ if _ , err := db .ExecContext (ctx , "SET @@autocommit = 0" ); err != nil {
779+ return nil , fmt .Errorf ("disable autocommit: %w" , err )
780+ }
781+ defer func () {
782+ _ , _ = db .ExecContext (context .Background (), "SET @@autocommit = 1" )
783+ }()
784+
785+ placeholders := make ([]string , len (ids ))
786+ args := make ([]interface {}, len (ids ))
787+ for i , id := range ids {
788+ placeholders [i ] = "?"
789+ args [i ] = id
790+ }
791+ updateQuery := fmt .Sprintf (
792+ "UPDATE `%s`.issues SET status = 'closed', closed_at = NOW() WHERE id IN (%s)" ,
793+ dbName , strings .Join (placeholders , "," ))
794+ if _ , err := db .ExecContext (ctx , updateQuery , args ... ); err != nil {
795+ return nil , fmt .Errorf ("close plugin receipts: %w" , err )
796+ }
797+
798+ // Flush and commit.
799+ if _ , err := db .ExecContext (ctx , "COMMIT" ); err != nil {
800+ result .Anomalies = append (result .Anomalies , Anomaly {
801+ Type : "sql_commit_failed" ,
802+ Message : fmt .Sprintf ("sql commit after plugin receipt close failed: %v" , err ),
803+ })
804+ return result , nil
805+ }
806+ commitMsg := fmt .Sprintf ("reaper: close %d plugin receipts in %s" , len (ids ), dbName )
807+ if _ , err := db .ExecContext (ctx , fmt .Sprintf ("CALL DOLT_COMMIT('-Am', '%s')" , commitMsg )); err != nil { //nolint:gosec // G201: commitMsg from safe values
808+ if ! isNothingToCommit (err ) {
809+ result .Anomalies = append (result .Anomalies , Anomaly {
810+ Type : "dolt_commit_failed" ,
811+ Message : fmt .Sprintf ("dolt commit after plugin receipt close failed: %v" , err ),
812+ })
813+ }
814+ }
815+
816+ return result , nil
817+ }
818+
727819// FormatJSON marshals any value to indented JSON.
728820func FormatJSON (v interface {}) string {
729821 data , err := json .MarshalIndent (v , "" , " " )
0 commit comments