-
Notifications
You must be signed in to change notification settings - Fork 0
Open
Description
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
- Related to: GitHub integration setup #1 (IssueQuery)
- Related to: Add Active Record entity layer (matching conduit-ui/pr pattern) #2 (IssueInstance)
Technical Notes
Higher-Order Proxy:
Laravel Collections support higher-order messages which allow chaining method calls:
$collection->each->method(); // Calls method() on each itemError Handling:
Batch operations should:
- Continue on individual failures
- Collect errors for reporting
- Return success/failure status for each operation
Performance:
For large batches:
- Consider rate limiting
- Add delay between operations if needed
- 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
coderabbitai
Metadata
Metadata
Assignees
Labels
No labels