Import closed issues as terminal tasks (#502)#732
Conversation
Fixes #502: issues closed before the first poll or between poll intervals were silently dropped because issue_to_task() returned None for non-open issues. Now closed issues are imported with a terminal state (Completed or Cancelled based on closure reason) so they are tracked as "seen". Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
This PR correctly solves the silent-drop problem for issues closed before or between polls. The approach — import closed issues as terminal tasks immediately — is the right design: it ensures deduplication works and avoids re-importing the same issue on future polls. Label guards (skip/ignore) run before the closed-state branch, preserving existing filter behavior. Tests cover the two main cases and the skip label invariant.
One important inconsistency to fix before shipping: issue_to_task maps Unknown closure reason to Completed (via _ fallthrough), while reconcile_task maps it to Cancelled. This means the terminal state of an issue with no state_reason differs depending on whether it was already closed at first poll time or observed transitioning during polling. See the inline comment for the fix.
Build: cargo test --workspace passes. cargo clippy --workspace -- -D warnings has one pre-existing error in crates/models/src/work_queue.rs (should_implement_trait for WorkType::from_str) — not introduced by this PR.
References:
Reviewed by PR / Review
| if is_closed { | ||
| task.state = match issue.classify_closure() { | ||
| Some(ClosureReason::NotPlanned) => TaskState::Cancelled, | ||
| _ => TaskState::Completed, | ||
| }; | ||
| return Some(task); | ||
| } |
There was a problem hiding this comment.
[IMPORTANT] — Correctness: Unknown closure reason is mapped inconsistently with reconcile_task.
The _ arm here catches ClosureReason::Unknown (issued closed with no state_reason) and maps it to Completed. But reconcile_task (line 297) maps Unknown → Cancelled:
// reconcile_task
ClosureReason::NotPlanned | ClosureReason::Unknown => TaskState::Cancelled,This means the terminal state of a task depends purely on timing: the same issue closed with no state_reason becomes Completed if it was already closed at first poll, but Cancelled if it was observed transitioning during reconciliation.
To stay consistent with the reconcile path:
if is_closed {
task.state = match issue.classify_closure() {
Some(ClosureReason::PrMerged | ClosureReason::ManualCompletion) => TaskState::Completed,
_ => TaskState::Cancelled, // NotPlanned, Unknown, and the unreachable None
};
return Some(task);
}A test for the Unknown case (closed with no state_reason) would lock in the expected behavior.
Summary
issue_to_task()previously skipped all non-open issues, causing issues closed before the first poll (or between poll intervals) to never be imported.Completed(default) orCancelled(if closed as "not planned").tasks/skipor a configured ignore label.Test plan
closed_issue_imported_as_completed— verifies default closed → Completedclosed_issue_not_planned_imported_as_cancelled— verifies NotPlanned → Cancelledclosed_issue_with_skip_label_not_imported— verifies skip label still respectedcargo checkpasses🤖 Generated with Claude Code