Skip to content

Implement Batch Operations and Higher-Order Collection Methods #27

@jordanpartridge

Description

@jordanpartridge

Summary

Implement batch operation support and higher-order collection methods for performing actions on multiple issues at once. This should leverage Laravel Collections' higher-order proxy pattern and provide elegant bulk operation APIs.

Requirements

Higher-Order Proxy Support

Enable Laravel's higher-order messaging pattern on issue collections:

// Batch operations using higher-order proxy
Issues::forRepo('owner/repo')
    ->whereLabel('stale')
    ->older(days: 90)
    ->get()
    ->each->addLabel('archived')
    ->each->close('Closing stale issue');

// Multiple operations
Issues::forRepo('owner/repo')
    ->whereLabel('needs-triage')
    ->get()
    ->each->removeLabel('needs-triage')
    ->each->addLabels(['triaged', 'ready']);

IssueCollection Class

File: src/Support/IssueCollection.php

namespace ConduitUI\Issue\Support;

use ConduitUI\Issue\Data\Issue;
use Illuminate\Support\Collection;

class IssueCollection extends Collection
{
    /**
     * Add a label to all issues in the collection
     */
    public function addLabel(string $label): self
    {
        return $this->each(function($issue) use ($label) {
            $issue->addLabel($label);
        });
    }
    
    /**
     * Add multiple labels to all issues
     */
    public function addLabels(array $labels): self
    {
        return $this->each(function($issue) use ($labels) {
            $issue->addLabels($labels);
        });
    }
    
    /**
     * Remove a label from all issues
     */
    public function removeLabel(string $label): self
    {
        return $this->each(function($issue) use ($label) {
            $issue->removeLabel($label);
        });
    }
    
    /**
     * Assign all issues to user(s)
     */
    public function assignTo(string|array $assignees): self
    {
        return $this->each(function($issue) use ($assignees) {
            $issue->assignTo($assignees);
        });
    }
    
    /**
     * Close all issues
     */
    public function closeAll(?string $reason = null, ?string $comment = null): self
    {
        return $this->each(function($issue) use ($reason, $comment) {
            if ($comment !== null) {
                $issue->comment($comment);
            }
            $issue->close($reason);
        });
    }
    
    /**
     * Reopen all issues
     */
    public function reopenAll(?string $comment = null): self
    {
        return $this->each(function($issue) use ($comment) {
            $issue->reopen();
            if ($comment !== null) {
                $issue->comment($comment);
            }
        });
    }
    
    /**
     * Lock all issues
     */
    public function lockAll(?string $reason = null): self
    {
        return $this->each(function($issue) use ($reason) {
            $issue->lock($reason);
        });
    }
    
    /**
     * Unlock all issues
     */
    public function unlockAll(): self
    {
        return $this->each(function($issue) {
            $issue->unlock();
        });
    }
    
    /**
     * Set milestone for all issues
     */
    public function setMilestone(int $milestoneNumber): self
    {
        return $this->each(function($issue) use ($milestoneNumber) {
            $issue->milestone($milestoneNumber);
        });
    }
    
    /**
     * Comment on all issues
     */
    public function commentAll(string $body): self
    {
        return $this->each(function($issue) use ($body) {
            $issue->comment($body);
        });
    }
    
    /**
     * Filter issues by label
     */
    public function withLabel(string $label): self
    {
        return $this->filter(fn($issue) => $issue->hasLabel($label));
    }
    
    /**
     * Filter issues without label
     */
    public function withoutLabel(string $label): self
    {
        return $this->reject(fn($issue) => $issue->hasLabel($label));
    }
    
    /**
     * Filter open issues
     */
    public function open(): self
    {
        return $this->filter(fn($issue) => $issue->isOpen());
    }
    
    /**
     * Filter closed issues
     */
    public function closed(): self
    {
        return $this->filter(fn($issue) => $issue->isClosed());
    }
    
    /**
     * Filter assigned issues
     */
    public function assigned(): self
    {
        return $this->filter(fn($issue) => $issue->assignees->isNotEmpty());
    }
    
    /**
     * Filter unassigned issues
     */
    public function unassigned(): self
    {
        return $this->filter(fn($issue) => $issue->assignees->isEmpty());
    }
    
    /**
     * Group by label
     */
    public function groupByLabel(): Collection
    {
        $grouped = new Collection();
        
        foreach ($this as $issue) {
            foreach ($issue->labels as $label) {
                if (!$grouped->has($label->name)) {
                    $grouped->put($label->name, new static());
                }
                $grouped->get($label->name)->push($issue);
            }
        }
        
        return $grouped;
    }
    
    /**
     * Group by state
     */
    public function groupByState(): Collection
    {
        return $this->groupBy('state');
    }
    
    /**
     * Group by assignee
     */
    public function groupByAssignee(): Collection
    {
        $grouped = new Collection();
        
        foreach ($this as $issue) {
            if ($issue->assignees->isEmpty()) {
                if (!$grouped->has('unassigned')) {
                    $grouped->put('unassigned', new static());
                }
                $grouped->get('unassigned')->push($issue);
            } else {
                foreach ($issue->assignees as $assignee) {
                    if (!$grouped->has($assignee->login)) {
                        $grouped->put($assignee->login, new static());
                    }
                    $grouped->get($assignee->login)->push($issue);
                }
            }
        }
        
        return $grouped;
    }
    
    /**
     * Get statistics
     */
    public function statistics(): array
    {
        return [
            'total' => $this->count(),
            'open' => $this->open()->count(),
            'closed' => $this->closed()->count(),
            'assigned' => $this->assigned()->count(),
            'unassigned' => $this->unassigned()->count(),
            'labels' => $this->pluck('labels')->flatten()->pluck('name')->unique()->sort()->values(),
            'assignees' => $this->pluck('assignees')->flatten()->pluck('login')->unique()->sort()->values(),
        ];
    }
}

Batch Operation Service

File: src/Services/BatchOperations.php

namespace ConduitUI\Issue\Services;

use ConduitUI\Connector\GitHub;
use ConduitUI\Issue\Support\IssueCollection;
use Illuminate\Support\Collection;

class BatchOperations
{
    public function __construct(
        protected GitHub $github,
        protected string $fullName,
    ) {}
    
    /**
     * Perform batch operation with progress tracking
     */
    public function batch(IssueCollection $issues, callable $operation, ?callable $progress = null): Collection
    {
        $results = new Collection();
        $total = $issues->count();
        $current = 0;
        
        foreach ($issues as $issue) {
            $current++;
            
            try {
                $result = $operation($issue);
                $results->push([
                    'issue' => $issue->number,
                    'success' => true,
                    'result' => $result,
                ]);
            } catch (\Exception $e) {
                $results->push([
                    'issue' => $issue->number,
                    'success' => false,
                    'error' => $e->getMessage(),
                ]);
            }
            
            if ($progress !== null) {
                $progress($current, $total, $issue);
            }
        }
        
        return $results;
    }
    
    /**
     * Close stale issues
     */
    public function closeStale(int $days, ?string $comment = null): Collection
    {
        $issues = (new IssueQuery($this->github))
            ->forRepo($this->fullName)
            ->whereOpen()
            ->older($days)
            ->get();
        
        return $this->batch($issues, function($issue) use ($comment) {
            if ($comment !== null) {
                $issue->comment($comment);
            }
            return $issue->close('not_planned');
        });
    }
    
    /**
     * Auto-triage by keywords
     */
    public function autoTriageByKeywords(array $keywords, array $labels): Collection
    {
        $issues = (new IssueQuery($this->github))
            ->forRepo($this->fullName)
            ->whereOpen()
            ->whereLabel('needs-triage')
            ->get();
        
        $matched = $issues->filter(function($issue) use ($keywords) {
            $text = strtolower($issue->title . ' ' . $issue->body);
            foreach ($keywords as $keyword) {
                if (str_contains($text, strtolower($keyword))) {
                    return true;
                }
            }
            return false;
        });
        
        return $this->batch($matched, function($issue) use ($labels) {
            return $issue
                ->removeLabel('needs-triage')
                ->addLabels($labels);
        });
    }
    
    /**
     * Bulk assign by label
     */
    public function assignByLabel(string $label, string|array $assignees): Collection
    {
        $issues = (new IssueQuery($this->github))
            ->forRepo($this->fullName)
            ->whereOpen()
            ->whereLabel($label)
            ->whereUnassigned()
            ->get();
        
        return $this->batch($issues, function($issue) use ($assignees) {
            return $issue->assignTo($assignees);
        });
    }
}

Integration with IssueQuery

Update: src/Services/IssueQuery.php

use ConduitUI\Issue\Support\IssueCollection;

public function get(): IssueCollection
{
    $endpoint = $this->buildEndpoint();
    $params = $this->buildParams();
    
    $response = $this->github->get($endpoint, $params);
    
    $issues = collect($response->json())
        ->map(fn (array $issue) => Issue::fromArray($issue));
    
    return new IssueCollection($issues);
}

Usage Examples

use ConduitUI\Issue\Facades\Issues;
use ConduitUI\Issue\Services\BatchOperations;

// Simple batch operations
Issues::forRepo('owner/repo')
    ->whereLabel('stale')
    ->older(days: 90)
    ->get()
    ->each->addLabel('archived')
    ->each->close('Closing stale issue');

// Batch with filtering
$issues = Issues::forRepo('owner/repo')
    ->whereOpen()
    ->get();

$bugs = $issues->withLabel('bug');
$bugs->assignTo('bug-team');

$unassigned = $issues->unassigned();
$unassigned->addLabels(['needs-assignment']);

// Statistics
$stats = Issues::forRepo('owner/repo')
    ->whereOpen()
    ->get()
    ->statistics();

// Grouping
$byAssignee = Issues::forRepo('owner/repo')
    ->whereOpen()
    ->get()
    ->groupByAssignee();

foreach ($byAssignee as $assignee => $issues) {
    echo "{$assignee} has {$issues->count()} issues\n";
}

// Batch operations service
$batch = new BatchOperations($github, 'owner/repo');

$batch->closeStale(
    days: 90,
    comment: 'Closing due to inactivity. Please reopen if still relevant.'
);

$batch->autoTriageByKeywords(
    keywords: ['bug', 'error', 'crash', 'broken'],
    labels: ['bug', 'needs-investigation']
);

$batch->assignByLabel('frontend', 'frontend-team');

// Progress tracking
$results = $batch->batch(
    $issues,
    fn($issue) => $issue->addLabel('processed'),
    fn($current, $total, $issue) => echo "Processing {$current}/{$total}: #{$issue->number}\n"
);

// Chained collection operations
Issues::forRepo('owner/repo')
    ->whereLabel('needs-review')
    ->get()
    ->filter(fn($issue) => $issue->comments > 5)
    ->assignTo('reviewer')
    ->commentAll('This issue is ready for review.');

Acceptance Criteria

  • IssueCollection class with batch methods
  • Higher-order proxy support (->each->method())
  • Collection filtering methods (withLabel, open, closed, etc.)
  • Grouping methods (byLabel, byState, byAssignee)
  • Statistics method
  • BatchOperations service class
  • Progress tracking support
  • Error handling in batch operations
  • IssueQuery returns IssueCollection
  • Full test coverage
  • Documentation with examples

Dependencies

Technical Notes

Higher-Order Proxy:
Laravel Collections support higher-order messages which allow chaining method calls:

$collection->each->method(); // Calls method() on each item

Error Handling:
Batch operations should:

  1. Continue on individual failures
  2. Collect errors for reporting
  3. Return success/failure status for each operation

Performance:
For large batches:

  1. Consider rate limiting
  2. Add delay between operations if needed
  3. Support chunking for very large datasets

Rate Limiting:
GitHub API has rate limits. Consider adding:

  • Automatic retry with backoff
  • Rate limit checking
  • Throttling for batch operations

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions