aidp 0.43.0 → 0.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -303,11 +303,35 @@ module Aidp
303
303
  create_project_field_via_gh(project_id, name, field_type, options: options)
304
304
  end
305
305
 
306
+ def create_project(title:, repository_id: nil)
307
+ raise "GitHub CLI not available - Projects API requires gh CLI" unless gh_available?
308
+ create_project_via_gh(title: title, repository_id: repository_id)
309
+ end
310
+
311
+ def find_active_project
312
+ raise "GitHub CLI not available - Projects API requires gh CLI" unless gh_available?
313
+ find_active_project_via_gh
314
+ end
315
+
316
+ def repository_node_data
317
+ raise "GitHub CLI not available - Projects API requires gh CLI" unless gh_available?
318
+ repository_node_data_via_gh
319
+ end
320
+
306
321
  def create_issue(title:, body:, labels: [], assignees: [])
307
322
  raise "GitHub CLI not available - cannot create issue" unless gh_available?
308
323
  create_issue_via_gh(title: title, body: body, labels: labels, assignees: assignees)
309
324
  end
310
325
 
326
+ def close_issue(number)
327
+ raise "GitHub CLI not available - cannot close issue" unless gh_available?
328
+ close_issue_via_gh(number)
329
+ end
330
+
331
+ def update_issue(number, title:, body:, labels:, assignees:)
332
+ gh_available? ? update_issue_via_gh(number, title: title, body: body, labels: labels, assignees: assignees) : update_issue_via_api(number, title: title, body: body, labels: labels, assignees: assignees)
333
+ end
334
+
311
335
  def merge_pull_request(number, merge_method: "squash")
312
336
  raise "GitHub CLI not available - cannot merge PR" unless gh_available?
313
337
  merge_pull_request_via_gh(number, merge_method: merge_method)
@@ -430,6 +454,47 @@ module Aidp
430
454
  end
431
455
  end
432
456
 
457
+ def update_issue_via_gh(number, title:, body:, labels:, assignees:)
458
+ with_gh_retry("update_issue") do
459
+ existing_issue = fetch_issue_via_gh(number)
460
+ cmd = ["gh", "issue", "edit", number.to_s, "--repo", full_repo, "--title", title, "--body", body]
461
+
462
+ label_changes(existing_issue[:labels], labels).each do |flag, values|
463
+ values.each { |value| cmd.concat([flag, value]) }
464
+ end
465
+
466
+ assignee_changes(existing_issue[:assignees], assignees).each do |flag, values|
467
+ values.each { |value| cmd.concat([flag, value]) }
468
+ end
469
+
470
+ _stdout, stderr, status = Open3.capture3(*cmd)
471
+ raise "Failed to update issue via gh: #{stderr.strip}" unless status.success?
472
+
473
+ true
474
+ end
475
+ end
476
+
477
+ def update_issue_via_api(number, title:, body:, labels:, assignees:)
478
+ uri = URI("https://api.github.com/repos/#{full_repo}/issues/#{number}")
479
+ request = Net::HTTP::Patch.new(uri)
480
+ request["Content-Type"] = "application/json"
481
+ request["Accept"] = "application/vnd.github.v3+json"
482
+ request.body = JSON.dump({
483
+ title: title,
484
+ body: body,
485
+ labels: labels,
486
+ assignees: assignees
487
+ })
488
+
489
+ response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
490
+ http.request(request)
491
+ end
492
+
493
+ raise "GitHub API issue update failed (#{response.code})" unless response.code.start_with?("2")
494
+
495
+ true
496
+ end
497
+
433
498
  def post_comment_via_api(number, body)
434
499
  uri = URI("https://api.github.com/repos/#{full_repo}/issues/#{number}/comments")
435
500
  request = Net::HTTP::Post.new(uri)
@@ -1358,6 +1423,20 @@ module Aidp
1358
1423
  end
1359
1424
  end
1360
1425
 
1426
+ def label_changes(existing_labels, next_labels)
1427
+ {
1428
+ "--remove-label" => Array(existing_labels) - Array(next_labels),
1429
+ "--add-label" => Array(next_labels) - Array(existing_labels)
1430
+ }
1431
+ end
1432
+
1433
+ def assignee_changes(existing_assignees, next_assignees)
1434
+ {
1435
+ "--remove-assignee" => Array(existing_assignees) - Array(next_assignees),
1436
+ "--add-assignee" => Array(next_assignees) - Array(existing_assignees)
1437
+ }
1438
+ end
1439
+
1361
1440
  def normalize_pr_comment(raw)
1362
1441
  {
1363
1442
  id: raw["id"],
@@ -1716,6 +1795,105 @@ module Aidp
1716
1795
  raise
1717
1796
  end
1718
1797
 
1798
+ def repository_node_data_via_gh
1799
+ Aidp.log_debug("repository_client", "repository_node_data", repo: full_repo)
1800
+
1801
+ query = <<~GRAPHQL
1802
+ query($owner: String!, $repo: String!) {
1803
+ repository(owner: $owner, name: $repo) {
1804
+ id
1805
+ owner {
1806
+ __typename
1807
+ login
1808
+ ... on Organization {
1809
+ id
1810
+ }
1811
+ ... on User {
1812
+ id
1813
+ }
1814
+ }
1815
+ }
1816
+ }
1817
+ GRAPHQL
1818
+
1819
+ result = execute_graphql_query(query, owner: owner, repo: repo)
1820
+ repo_data = result.dig("data", "repository")
1821
+ raise "Repository not found: #{full_repo}" unless repo_data
1822
+
1823
+ {
1824
+ repository_id: repo_data["id"],
1825
+ owner_id: repo_data.dig("owner", "id"),
1826
+ owner_login: repo_data.dig("owner", "login"),
1827
+ owner_type: repo_data.dig("owner", "__typename")
1828
+ }
1829
+ rescue => e
1830
+ Aidp.log_error("repository_client", "repository_node_data_failed", repo: full_repo, error: e.message)
1831
+ raise
1832
+ end
1833
+
1834
+ def create_project_via_gh(title:, repository_id: nil)
1835
+ owner_data = repository_node_data_via_gh
1836
+
1837
+ mutation = <<~GRAPHQL
1838
+ mutation($ownerId: ID!, $title: String!, $repositoryId: ID) {
1839
+ createProjectV2(input: {
1840
+ ownerId: $ownerId
1841
+ title: $title
1842
+ repositoryId: $repositoryId
1843
+ }) {
1844
+ projectV2 {
1845
+ id
1846
+ title
1847
+ number
1848
+ url
1849
+ }
1850
+ }
1851
+ }
1852
+ GRAPHQL
1853
+
1854
+ result = execute_graphql_query(
1855
+ mutation,
1856
+ ownerId: owner_data[:owner_id],
1857
+ title: title,
1858
+ repositoryId: repository_id || owner_data[:repository_id]
1859
+ )
1860
+ project_data = result.dig("data", "createProjectV2", "projectV2")
1861
+ raise "Failed to create project: #{title}" unless project_data
1862
+
1863
+ normalize_project(project_data)
1864
+ rescue => e
1865
+ Aidp.log_error("repository_client", "create_project_failed", repo: full_repo, title: title, error: e.message)
1866
+ raise
1867
+ end
1868
+
1869
+ def find_active_project_via_gh
1870
+ Aidp.log_debug("repository_client", "find_active_project", repo: full_repo)
1871
+
1872
+ query = <<~GRAPHQL
1873
+ query($owner: String!, $repo: String!) {
1874
+ repository(owner: $owner, name: $repo) {
1875
+ projectsV2(first: 20, orderBy: {field: UPDATED_AT, direction: DESC}) {
1876
+ nodes {
1877
+ id
1878
+ title
1879
+ number
1880
+ url
1881
+ closed
1882
+ }
1883
+ }
1884
+ }
1885
+ }
1886
+ GRAPHQL
1887
+
1888
+ result = execute_graphql_query(query, owner: owner, repo: repo)
1889
+ projects = Array(result.dig("data", "repository", "projectsV2", "nodes"))
1890
+ active_project = projects.find { |project| !project["closed"] }
1891
+ normalize_project(active_project) if active_project
1892
+ rescue => e
1893
+ Aidp.log_error("repository_client", "find_active_project_failed", repo: full_repo, error: e.message)
1894
+ raise
1895
+ end
1896
+
1719
1897
  def create_issue_via_gh(title:, body:, labels: [], assignees: [])
1720
1898
  Aidp.log_debug("repository_client", "create_issue", title: title, label_count: labels.size, assignee_count: assignees.size)
1721
1899
 
@@ -1737,6 +1915,19 @@ module Aidp
1737
1915
  raise
1738
1916
  end
1739
1917
 
1918
+ def close_issue_via_gh(number)
1919
+ Aidp.log_debug("repository_client", "close_issue", issue_number: number)
1920
+
1921
+ cmd = ["gh", "issue", "close", number.to_s, "--repo", full_repo]
1922
+ _stdout, stderr, status = Open3.capture3(*cmd)
1923
+ raise "Failed to close issue via gh: #{stderr.strip}" unless status.success?
1924
+
1925
+ Aidp.log_debug("repository_client", "close_issue_complete", issue_number: number)
1926
+ rescue => e
1927
+ Aidp.log_error("repository_client", "Failed to close issue", issue_number: number, error: e.message)
1928
+ raise
1929
+ end
1930
+
1740
1931
  def merge_pull_request_via_gh(number, merge_method: "squash")
1741
1932
  Aidp.log_debug("repository_client", "merge_pull_request", number: number, merge_method: merge_method)
1742
1933
 
@@ -1785,11 +1976,14 @@ module Aidp
1785
1976
  end
1786
1977
 
1787
1978
  def normalize_project(raw)
1979
+ return nil unless raw
1980
+
1788
1981
  {
1789
1982
  id: raw["id"],
1790
1983
  title: raw["title"],
1791
1984
  number: raw["number"],
1792
1985
  url: raw["url"],
1986
+ closed: raw["closed"],
1793
1987
  fields: Array(raw.dig("fields", "nodes")).map { |field| normalize_project_field(field) }
1794
1988
  }
1795
1989
  end
@@ -3,6 +3,7 @@
3
3
  require "tty-prompt"
4
4
  require_relative "feedback_collector"
5
5
  require_relative "github_state_extractor"
6
+ require_relative "projects_processor"
6
7
  require_relative "round_robin_scheduler"
7
8
  require_relative "work_item"
8
9
  require_relative "worktree_cleanup_job"
@@ -43,6 +44,7 @@ module Aidp
43
44
 
44
45
  # Extract label configuration from safety_config (it's actually the full watch config)
45
46
  label_config = safety_config[:labels] || safety_config["labels"] || {}
47
+ @project_config = safety_config[:projects] || safety_config["projects"] || {}
46
48
 
47
49
  # Extract detection comment configuration (issue #280)
48
50
  # Enabled by default, can be disabled in config
@@ -58,7 +60,8 @@ module Aidp
58
60
  repository_client: @repository_client,
59
61
  state_store: @state_store,
60
62
  plan_generator: PlanGenerator.new(provider_name: provider_name, verbose: verbose),
61
- label_config: label_config
63
+ label_config: label_config,
64
+ project_config: @project_config
62
65
  )
63
66
  @build_processor = BuildProcessor.new(
64
67
  repository_client: @repository_client,
@@ -269,7 +272,7 @@ module Aidp
269
272
  # Dispatch to processor
270
273
  case item.processor_type
271
274
  when :plan
272
- @plan_processor.process(detailed)
275
+ @plan_processor.process(detailed, trigger_label: item.label)
273
276
  when :build
274
277
  # Check build completion at dispatch time (moved from collection for API efficiency)
275
278
  if @state_extractor.build_completed?(detailed)
@@ -336,6 +339,7 @@ module Aidp
336
339
  items = []
337
340
 
338
341
  items.concat(collect_plan_work_items)
342
+ items.concat(collect_project_work_items)
339
343
  items.concat(collect_build_work_items)
340
344
  items.concat(collect_auto_issue_work_items)
341
345
  items.concat(collect_review_work_items)
@@ -353,10 +357,12 @@ module Aidp
353
357
  # @return [Array<WorkItem>]
354
358
  def collect_plan_work_items
355
359
  label = @plan_processor.plan_label
360
+ project_label = @plan_processor.project_label
356
361
  issues = @repository_client.list_issues(labels: [label], state: "open")
357
362
 
358
363
  issues.filter_map do |issue|
359
364
  next unless issue_has_label?(issue, label)
365
+ next if issue_has_label?(issue, project_label)
360
366
 
361
367
  WorkItem.new(
362
368
  number: issue[:number],
@@ -371,30 +377,56 @@ module Aidp
371
377
  []
372
378
  end
373
379
 
374
- # Collect work items for build triggers.
375
- # @return [Array<WorkItem>]
376
- def collect_build_work_items
377
- label = @build_processor.build_label
380
+ def collect_project_work_items
381
+ label = @plan_processor.project_label
378
382
  issues = @repository_client.list_issues(labels: [label], state: "open")
379
383
 
380
384
  issues.filter_map do |issue|
381
385
  next unless issue_has_label?(issue, label)
382
386
 
383
- # Note: build_completed check moved to dispatch phase to avoid
384
- # API calls during collection (addresses rate limiting concerns)
385
387
  WorkItem.new(
386
388
  number: issue[:number],
387
389
  item_type: :issue,
388
- processor_type: :build,
390
+ processor_type: :plan,
389
391
  label: label,
390
392
  data: issue
391
393
  )
392
394
  end
395
+ rescue => e
396
+ Aidp.log_error("watch_runner", "collect_project_items_failed", error: e.message)
397
+ []
398
+ end
399
+
400
+ # Collect work items for build triggers.
401
+ # @return [Array<WorkItem>]
402
+ def collect_build_work_items
403
+ unblock_dependency_ready_items
404
+
405
+ standard_build_work_items + ready_project_work_items
393
406
  rescue => e
394
407
  Aidp.log_error("watch_runner", "collect_build_items_failed", error: e.message)
395
408
  []
396
409
  end
397
410
 
411
+ def unblock_dependency_ready_items
412
+ blocked_label = @plan_processor.blocked_label
413
+ blocked_issues = @repository_client.list_issues(labels: [blocked_label], state: "open")
414
+
415
+ blocked_issues.each do |issue|
416
+ next unless issue_has_label?(issue, blocked_label)
417
+ next unless dependencies_met_for_issue?(issue[:number])
418
+
419
+ @repository_client.replace_labels(
420
+ issue[:number],
421
+ old_labels: [blocked_label],
422
+ new_labels: [unblocked_label_for(issue[:number])]
423
+ )
424
+ sync_project_status_for_unblocked_issue(issue[:number])
425
+ end
426
+ rescue => e
427
+ Aidp.log_error("watch_runner", "unblock_dependency_ready_items_failed", error: e.message)
428
+ end
429
+
398
430
  # Collect work items for auto issue triggers.
399
431
  # @return [Array<WorkItem>]
400
432
  def collect_auto_issue_work_items
@@ -529,6 +561,82 @@ module Aidp
529
561
  end
530
562
  end
531
563
 
564
+ def standard_build_work_items
565
+ label = @build_processor.build_label
566
+ project_label = @plan_processor.project_label
567
+ issues = @repository_client.list_issues(labels: [label], state: "open")
568
+
569
+ issues.filter_map do |issue|
570
+ next unless issue_has_label?(issue, label)
571
+ next if issue_has_label?(issue, project_label)
572
+
573
+ build_work_item_for(issue, label: label)
574
+ end
575
+ end
576
+
577
+ def ready_project_work_items
578
+ label = @plan_processor.ready_label
579
+ issues = @repository_client.list_issues(labels: [label], state: "open")
580
+
581
+ issues.filter_map do |issue|
582
+ next unless issue_has_label?(issue, label)
583
+ next unless @state_store.sub_issues(issue[:number]).any?
584
+
585
+ build_work_item_for(issue, label: label)
586
+ end
587
+ end
588
+
589
+ def build_work_item_for(issue, label:)
590
+ # Note: build_completed check moved to dispatch phase to avoid
591
+ # API calls during collection (addresses rate limiting concerns)
592
+ WorkItem.new(
593
+ number: issue[:number],
594
+ item_type: :issue,
595
+ processor_type: :build,
596
+ label: label,
597
+ data: issue
598
+ )
599
+ end
600
+
601
+ def dependencies_met_for_issue?(issue_number)
602
+ blocking_status = @state_store.blocking_status(issue_number)
603
+ return false unless blocking_status[:blocked]
604
+
605
+ blocking_status[:blockers].all? do |blocker_number|
606
+ blocker = @repository_client.fetch_issue(blocker_number)
607
+ blocker[:state].to_s.casecmp("closed").zero?
608
+ end
609
+ rescue => e
610
+ Aidp.log_warn("watch_runner", "dependency_check_failed",
611
+ issue: issue_number, error: e.message)
612
+ false
613
+ end
614
+
615
+ def unblocked_label_for(issue_number)
616
+ return @plan_processor.ready_label if @state_store.sub_issues(issue_number).any?
617
+
618
+ @build_processor.build_label
619
+ end
620
+
621
+ def sync_project_status_for_unblocked_issue(issue_number)
622
+ project_id = @state_store.project_sync_data(issue_number)["project_id"]
623
+ return unless project_id
624
+
625
+ projects_processor = ProjectsProcessor.new(
626
+ repository_client: @repository_client,
627
+ state_store: @state_store,
628
+ project_id: project_id,
629
+ config: @project_config
630
+ )
631
+ return if projects_processor.sync_issue_to_project(issue_number, status: ProjectsProcessor::STATUS_VALUES[:todo])
632
+
633
+ Aidp.log_warn("watch_runner", "project_status_sync_failed",
634
+ issue: issue_number, project_id: project_id)
635
+ rescue => e
636
+ Aidp.log_warn("watch_runner", "project_status_sync_failed",
637
+ issue: issue_number, project_id: project_id, error: e.message)
638
+ end
639
+
532
640
  # Restore from checkpoint if one exists (after auto-update)
533
641
  def restore_from_checkpoint_if_exists
534
642
  return unless @auto_update_coordinator.policy.enabled
@@ -373,6 +373,7 @@ module Aidp
373
373
 
374
374
  def record_sub_issues(parent_number, sub_issue_numbers)
375
375
  hierarchies[parent_number.to_s] ||= {}
376
+ clear_stale_sub_issue_tracking(parent_number, Array(sub_issue_numbers))
376
377
  hierarchies[parent_number.to_s]["sub_issues"] = Array(sub_issue_numbers)
377
378
  hierarchies[parent_number.to_s]["created_at"] = Time.now.utc.iso8601
378
379
 
@@ -385,8 +386,47 @@ module Aidp
385
386
  save!
386
387
  end
387
388
 
389
+ def clear_sub_issues(parent_number)
390
+ record_sub_issues(parent_number, [])
391
+ end
392
+
393
+ def issue_dependencies(issue_number)
394
+ Array(hierarchies[issue_number.to_s]&.dig("dependencies"))
395
+ end
396
+
397
+ def record_issue_dependencies(issue_number, dependency_numbers)
398
+ hierarchies[issue_number.to_s] ||= {}
399
+ hierarchies[issue_number.to_s]["dependencies"] = Array(dependency_numbers)
400
+ hierarchies[issue_number.to_s]["dependencies_updated_at"] = Time.now.utc.iso8601
401
+ save!
402
+ end
403
+
404
+ private
405
+
406
+ def clear_stale_sub_issue_tracking(parent_number, next_sub_issue_numbers)
407
+ stale_numbers = sub_issues(parent_number) - next_sub_issue_numbers
408
+ stale_numbers.each do |sub_number|
409
+ hierarchy = hierarchies[sub_number.to_s]
410
+ next unless hierarchy
411
+
412
+ hierarchy.delete("parent")
413
+ hierarchy.delete("dependencies")
414
+ hierarchy.delete("dependencies_updated_at")
415
+ end
416
+ end
417
+
418
+ public
419
+
388
420
  def blocking_status(issue_number)
389
- # Check if this issue is blocked by any open sub-issues
421
+ dependency_numbers = issue_dependencies(issue_number)
422
+ if dependency_numbers.any?
423
+ return {
424
+ blocked: true,
425
+ blockers: dependency_numbers,
426
+ blocker_count: dependency_numbers.size
427
+ }
428
+ end
429
+
390
430
  sub_issue_numbers = sub_issues(issue_number)
391
431
  return {blocked: false, blockers: []} if sub_issue_numbers.empty?
392
432