aidp 0.43.0 → 0.45.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"],
@@ -1565,6 +1644,21 @@ module Aidp
1565
1644
  }
1566
1645
  }
1567
1646
  GRAPHQL
1647
+ elsif value.is_a?(Hash) && value[:date]
1648
+ <<~GRAPHQL
1649
+ mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $date: Date!) {
1650
+ updateProjectV2ItemFieldValue(input: {
1651
+ projectId: $projectId
1652
+ itemId: $itemId
1653
+ fieldId: $fieldId
1654
+ value: {date: $date}
1655
+ }) {
1656
+ projectV2Item {
1657
+ id
1658
+ }
1659
+ }
1660
+ }
1661
+ GRAPHQL
1568
1662
  else
1569
1663
  # Text field
1570
1664
  <<~GRAPHQL
@@ -1596,8 +1690,10 @@ module Aidp
1596
1690
 
1597
1691
  if value.is_a?(Hash) && value[:option_id]
1598
1692
  variables[:optionId] = value[:option_id]
1693
+ elsif value.is_a?(Hash) && value[:date]
1694
+ variables[:date] = value[:date]
1599
1695
  else
1600
- variables[:text] = value.to_s
1696
+ variables[:text] = value.is_a?(Hash) ? value[:text].to_s : value.to_s
1601
1697
  end
1602
1698
 
1603
1699
  result = execute_graphql_query(mutation, **variables)
@@ -1716,6 +1812,105 @@ module Aidp
1716
1812
  raise
1717
1813
  end
1718
1814
 
1815
+ def repository_node_data_via_gh
1816
+ Aidp.log_debug("repository_client", "repository_node_data", repo: full_repo)
1817
+
1818
+ query = <<~GRAPHQL
1819
+ query($owner: String!, $repo: String!) {
1820
+ repository(owner: $owner, name: $repo) {
1821
+ id
1822
+ owner {
1823
+ __typename
1824
+ login
1825
+ ... on Organization {
1826
+ id
1827
+ }
1828
+ ... on User {
1829
+ id
1830
+ }
1831
+ }
1832
+ }
1833
+ }
1834
+ GRAPHQL
1835
+
1836
+ result = execute_graphql_query(query, owner: owner, repo: repo)
1837
+ repo_data = result.dig("data", "repository")
1838
+ raise "Repository not found: #{full_repo}" unless repo_data
1839
+
1840
+ {
1841
+ repository_id: repo_data["id"],
1842
+ owner_id: repo_data.dig("owner", "id"),
1843
+ owner_login: repo_data.dig("owner", "login"),
1844
+ owner_type: repo_data.dig("owner", "__typename")
1845
+ }
1846
+ rescue => e
1847
+ Aidp.log_error("repository_client", "repository_node_data_failed", repo: full_repo, error: e.message)
1848
+ raise
1849
+ end
1850
+
1851
+ def create_project_via_gh(title:, repository_id: nil)
1852
+ owner_data = repository_node_data_via_gh
1853
+
1854
+ mutation = <<~GRAPHQL
1855
+ mutation($ownerId: ID!, $title: String!, $repositoryId: ID) {
1856
+ createProjectV2(input: {
1857
+ ownerId: $ownerId
1858
+ title: $title
1859
+ repositoryId: $repositoryId
1860
+ }) {
1861
+ projectV2 {
1862
+ id
1863
+ title
1864
+ number
1865
+ url
1866
+ }
1867
+ }
1868
+ }
1869
+ GRAPHQL
1870
+
1871
+ result = execute_graphql_query(
1872
+ mutation,
1873
+ ownerId: owner_data[:owner_id],
1874
+ title: title,
1875
+ repositoryId: repository_id || owner_data[:repository_id]
1876
+ )
1877
+ project_data = result.dig("data", "createProjectV2", "projectV2")
1878
+ raise "Failed to create project: #{title}" unless project_data
1879
+
1880
+ normalize_project(project_data)
1881
+ rescue => e
1882
+ Aidp.log_error("repository_client", "create_project_failed", repo: full_repo, title: title, error: e.message)
1883
+ raise
1884
+ end
1885
+
1886
+ def find_active_project_via_gh
1887
+ Aidp.log_debug("repository_client", "find_active_project", repo: full_repo)
1888
+
1889
+ query = <<~GRAPHQL
1890
+ query($owner: String!, $repo: String!) {
1891
+ repository(owner: $owner, name: $repo) {
1892
+ projectsV2(first: 20, orderBy: {field: UPDATED_AT, direction: DESC}) {
1893
+ nodes {
1894
+ id
1895
+ title
1896
+ number
1897
+ url
1898
+ closed
1899
+ }
1900
+ }
1901
+ }
1902
+ }
1903
+ GRAPHQL
1904
+
1905
+ result = execute_graphql_query(query, owner: owner, repo: repo)
1906
+ projects = Array(result.dig("data", "repository", "projectsV2", "nodes"))
1907
+ active_project = projects.find { |project| !project["closed"] }
1908
+ normalize_project(active_project) if active_project
1909
+ rescue => e
1910
+ Aidp.log_error("repository_client", "find_active_project_failed", repo: full_repo, error: e.message)
1911
+ raise
1912
+ end
1913
+
1719
1914
  def create_issue_via_gh(title:, body:, labels: [], assignees: [])
1720
1915
  Aidp.log_debug("repository_client", "create_issue", title: title, label_count: labels.size, assignee_count: assignees.size)
1721
1916
 
@@ -1737,6 +1932,19 @@ module Aidp
1737
1932
  raise
1738
1933
  end
1739
1934
 
1935
+ def close_issue_via_gh(number)
1936
+ Aidp.log_debug("repository_client", "close_issue", issue_number: number)
1937
+
1938
+ cmd = ["gh", "issue", "close", number.to_s, "--repo", full_repo]
1939
+ _stdout, stderr, status = Open3.capture3(*cmd)
1940
+ raise "Failed to close issue via gh: #{stderr.strip}" unless status.success?
1941
+
1942
+ Aidp.log_debug("repository_client", "close_issue_complete", issue_number: number)
1943
+ rescue => e
1944
+ Aidp.log_error("repository_client", "Failed to close issue", issue_number: number, error: e.message)
1945
+ raise
1946
+ end
1947
+
1740
1948
  def merge_pull_request_via_gh(number, merge_method: "squash")
1741
1949
  Aidp.log_debug("repository_client", "merge_pull_request", number: number, merge_method: merge_method)
1742
1950
 
@@ -1785,11 +1993,14 @@ module Aidp
1785
1993
  end
1786
1994
 
1787
1995
  def normalize_project(raw)
1996
+ return nil unless raw
1997
+
1788
1998
  {
1789
1999
  id: raw["id"],
1790
2000
  title: raw["title"],
1791
2001
  number: raw["number"],
1792
2002
  url: raw["url"],
2003
+ closed: raw["closed"],
1793
2004
  fields: Array(raw.dig("fields", "nodes")).map { |field| normalize_project_field(field) }
1794
2005
  }
1795
2006
  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