ace-git 0.23.1 → 0.25.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f4630b03ca193f78f73339cb3c2a9b8fbb07c7ca9545b1132cfd66f3e0dda160
4
- data.tar.gz: 60096f6410637684ef3753300a2bf3195a07052170032774be9ea3e66e4c0d5d
3
+ metadata.gz: '089e377ec56099fb872544bfc754f6f95f00ecaf157715ecad93e726e9c8960f'
4
+ data.tar.gz: 35bb8b556655c0ef1804fb2fe26039dd182115e558127254c49e17da9ce3f1ba
5
5
  SHA512:
6
- metadata.gz: 285125f8923236d4ba4ae5186a20b354eaa978019c5c17eb37fbf6b9bd96057ad0901549f6ae94f690bbc67268dbd7aa692238a9f07a88c8d41fe58c3ad8765d
7
- data.tar.gz: 4d8da76f2676ea2c09434b8a8afa2589a643bd00b5140614286c1be12a5f696adbbd7ce10c079eab35f8efdc24e42ad7fdb8e9e9d5a8bcb58763961c79e9086e
6
+ metadata.gz: c156b7e8b123f15f9d305d27933d68de72d640e16a803a9d7b5ce2cce12c1c7cf537bb7e6ae31e7c8ca044999869160ccf835281fdcc51e3bee76589cd43363f
7
+ data.tar.gz: 4eaeac69d71391c491718c68e68e1599a0a3e1991eae4a25d3e4e2a922959ef13e940c7d863eae01478a78ae9f81940a016fcac46da27ee7bbdf427f64527a03
@@ -10,7 +10,27 @@ git:
10
10
 
11
11
  # Timeout settings (in seconds)
12
12
  timeout: 30 # Local git operations (diff, status, log)
13
- network_timeout: 60 # Network operations (gh CLI, remote operations)
13
+ network_timeout: 60 # Network operations (provider CLIs, remote operations)
14
+
15
+ # Forge server registry (provider-neutral)
16
+ # Configure each forge server once with a unique name; mark at most one as
17
+ # default. Resolve by explicit name, by default, or by matching a git remote
18
+ # URL. The `provider` value selects the registered provider package that owns
19
+ # that server's behavior.
20
+ servers: []
21
+ # servers:
22
+ # - name: forgejo-lab
23
+ # provider: forgejo
24
+ # url: https://forgejo.example.com/owner/repo
25
+ # default: true
26
+ # - name: github-public
27
+ # provider: github
28
+ # url: https://github.example.com/owner/repo
29
+
30
+ # Provider packages required on first use (library names under ace/git/*).
31
+ # Both shipped provider gems are loaded by default; remove entries for
32
+ # providers you do not use.
33
+ providers: [github, forgejo]
14
34
 
15
35
  # Status command settings
16
36
  status:
data/CHANGELOG.md CHANGED
@@ -7,6 +7,73 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.25.0] - 2026-09-28
11
+
12
+ ### Added
13
+ - Forge-neutral `ace-git pr` lifecycle CLI: `show`, `create`, `update`, `ready`, `merge`
14
+ with `--server` / `--default-server` selection and `--format json`.
15
+ - Provider contract for PR lifecycle mutations (exact base/head identity,
16
+ idempotent create reconciliation, expected-head enforcement, unknown-outcome
17
+ classification) on `Ace::Git::Providers::Base`.
18
+ - Normalized evidence types: PR provenance fields (`head_repository_url`,
19
+ `base_repository_url`, `merge_commit_sha`), `ProviderPullRequestIdentity`,
20
+ `ProviderMutationReceipt`, `ProviderCleanupProof` with the cleanup proof
21
+ status vocabulary.
22
+ - Classified lifecycle failures: identity mismatch, conflicting exact matches,
23
+ expected-head conflict, unsupported capability, unknown mutation outcome.
24
+ - Server selection helpers `ServerRegistry.resolve_for`, `matching_servers`,
25
+ and `servers_for_owner_repo`; `PrReference` atom for `NUMBER`,
26
+ `owner/repo#NUMBER`, and URL identifiers.
27
+ - Config-driven provider loading via `git.providers` (default: both shipped
28
+ provider gems).
29
+
30
+ ### Changed
31
+ - PR identifiers now validate repository identity against the resolved server;
32
+ mismatches fail before any mutation. `owner/repo#NUMBER` requires exactly one
33
+ configured matching server and never assumes github.com.
34
+
35
+
36
+ ### Added
37
+ - Forge-neutral `ace-git pr` lifecycle CLI: `show`, `create`, `update`, `ready`, `merge`
38
+ with `--server` / `--default-server` selection and `--format json`.
39
+ - Provider contract for PR lifecycle mutations (exact base/head identity,
40
+ idempotent create reconciliation, expected-head enforcement, unknown-outcome
41
+ classification) on `Ace::Git::Providers::Base`.
42
+ - Normalized evidence types: PR provenance fields (`head_repository_url`,
43
+ `base_repository_url`, `merge_commit_sha`), `ProviderPullRequestIdentity`,
44
+ `ProviderMutationReceipt`, `ProviderCleanupProof` with the cleanup proof
45
+ status vocabulary.
46
+ - Classified lifecycle failures: identity mismatch, conflicting exact matches,
47
+ expected-head conflict, unsupported capability, unknown mutation outcome.
48
+ - Server selection helpers `ServerRegistry.resolve_for`, `matching_servers`,
49
+ and `servers_for_owner_repo`; `PrReference` atom for `NUMBER`,
50
+ `owner/repo#NUMBER`, and URL identifiers.
51
+ - Config-driven provider loading via `git.providers` (default: both shipped
52
+ provider gems).
53
+
54
+ ### Changed
55
+ - PR identifiers now validate repository identity against the resolved server;
56
+ mismatches fail before any mutation. `owner/repo#NUMBER` requires exactly one
57
+ configured matching server and never assumes github.com.
58
+
59
+
60
+ ## [0.24.0] - 2026-09-21
61
+
62
+ ### Added
63
+
64
+ - Forge-neutral provider foundation: `Ace::Git::ServerRegistry` (explicit named servers, exactly-one-default resolution, deterministic remote-URL matching with no hostname assumptions), `Ace::Git::ResolvedServer`, the `Ace::Git::Providers` registry with the `Providers::Base` contract, normalized evidence types (`ProviderPullRequest`, `ProviderIssue`, `ProviderCheck`, `ProviderRepository`), and the neutral `Atoms::PrIdentifier` / `Atoms::ServerUrl` atoms.
65
+ - Classified failure taxonomy in `git/errors.rb`: `NoDefaultServerConfiguredError`, `MultipleDefaultServersError`, `DuplicateServerNameError`, `UnknownProviderError`, `UnknownServerNameError`, `AmbiguousRemoteError`, `ProviderCliMissingError`, `ProviderAuthenticationError`, `ProviderUnreachableError`, `ProviderMalformedOutputError`, `ProviderObjectNotFoundError`.
66
+ - `git.servers` configuration section supporting multiple uniquely named servers with at most one explicit default.
67
+ - Boundary proof: local Git operations succeed with provider CLIs scrubbed from PATH, and a test pins the core lib to zero forge CLI references.
68
+
69
+ ### Changed
70
+
71
+ - Status PR enrichment routes through the provider contract (default server + registered provider); with no server or provider configured, status stays purely local and PR sections are skipped.
72
+ - Forge server URLs in gemspec metadata point at the canonical delivery forge instead of hardcoded GitHub URLs.
73
+
74
+ ### Removed
75
+
76
+ - All forge behavior from the core (pre-1.0 replacement, no shims or aliases): `GhCliExecutor`, `PrMetadataFetcher`, `GithubIssueSync`, the `ace-git pr` CLI command, the GitHub-specific `PrIdentifierParser`, and the `GhNotInstalledError` / `GhAuthenticationError` / `PrNotFoundError` error classes. GitHub behavior moved to the new `ace-git-github` gem; Forgejo behavior is provided by the new `ace-git-forgejo` gem.
10
77
 
11
78
  ## [0.23.1] - 2026-09-02
12
79
 
@@ -17,7 +17,8 @@ guided workflow.
17
17
  - Ruby 3.2+
18
18
  - Git 2.23+
19
19
  - `ace-git` installed
20
- - Optional: GitHub CLI (`gh`) for PR-aware commands and workflows
20
+ - Optional: a provider package (`ace-git-github` or `ace-git-forgejo`) plus a configured
21
+ default forge server for PR-aware status enrichment
21
22
 
22
23
  ## Installation
23
24
 
@@ -76,7 +77,6 @@ Use this for preferences, not for redefining the workflow logic.
76
77
  | Show repo context | `ace-git status` |
77
78
  | Show a quick diff summary | `ace-git diff --format summary` |
78
79
  | Show branch tracking info | `ace-git branch` |
79
- | Show PR metadata | `ace-git pr` |
80
80
  | Show package version | `ace-git version` |
81
81
 
82
82
  ## Run Package Tests
@@ -90,7 +90,7 @@ Use the restarted package test model:
90
90
 
91
91
  ## Next Steps
92
92
 
93
- - Load `wfi://github/pr/create` to open a structured PR workflow
94
- - Load `wfi://github/pr/update` to refresh an existing PR description
95
93
  - Load `wfi://git/reorganize-commits` to clean up branch history before review
94
+ - Install a provider package (`ace-git-github` / `ace-git-forgejo`) and configure
95
+ `git.servers` for PR-aware status enrichment
96
96
  - Run `ace-git --help` to browse command-level examples
data/docs/usage.md CHANGED
@@ -23,12 +23,12 @@ gem install ace-git
23
23
 
24
24
  ## Command Overview
25
25
 
26
- `ace-git` ships five commands:
26
+ `ace-git` ships the following commands:
27
27
 
28
28
  - `diff` for filtered or formatted git diffs
29
29
  - `status` for repository context and PR activity
30
30
  - `branch` for current branch and tracking state
31
- - `pr` for PR metadata lookup
31
+ - `pr` for the forge-neutral pull request lifecycle (`show`, `create`, `update`, `ready`, `merge`)
32
32
  - `version` for the installed package version
33
33
 
34
34
  `ace-git` with no arguments shows help. Git range shorthand such as `HEAD~5..HEAD` routes to `diff`.
@@ -155,42 +155,50 @@ ace-git branch --format json
155
155
  **Options:**
156
156
  - `--format, -f` - Output format: `text` (default), `json`
157
157
 
158
- ### `ace-git pr [NUMBER]`
158
+ ### `ace-git pr`
159
159
 
160
- Fetch and display PR metadata using GitHub CLI.
160
+ Forge-neutral pull request lifecycle over the configured forge servers
161
+ (`git.servers`). Every subcommand accepts `--server NAME` / `--default-server`
162
+ (mutually exclusive; with neither, the configured repository remote is
163
+ resolved) and `--format json`. Server, provider, and the exact head SHA are
164
+ part of every result; failures are classified and exit nonzero.
161
165
 
162
166
  ```bash
163
- # Auto-detect PR from current branch
164
- ace-git pr
165
-
166
- # Specific PR number
167
- ace-git pr 123
168
-
169
- # Cross-repository PR
170
- ace-git pr owner/repo#456
171
-
172
- # JSON output
173
- ace-git pr --format json
167
+ # Show normalized PR evidence (number, owner/repo#number, or URL)
168
+ ace-git pr show 25
169
+ ace-git pr show cs3b/ace#25 --format json
170
+ ace-git pr show https://forge.example.com/cs3b/ace/pull/25 --server forgejo-lab
171
+
172
+ # Create (or reconcile to) a draft PR for a pushed branch.
173
+ # The pushed head SHA is proven against the PR; draft is the default.
174
+ ace-git pr create --head feature --base main \
175
+ --expected-head "$(git rev-parse HEAD)" \
176
+ --title "Add feature" --body-file /tmp/body.md --format json
177
+
178
+ # Fork PRs name the source repository explicitly; the base repository is
179
+ # always the selected configured server. No fork inference happens.
180
+ ace-git pr create --head feature --head-repo https://forge.example.com/fork/ace \
181
+ --base main --expected-head SHA --title "Fork PR" --draft
182
+
183
+ # Update title/body after head verification
184
+ ace-git pr update 25 --expected-head SHA --title "New title" --body-file /tmp/body.md
185
+
186
+ # Mark a draft ready (provider capability; unsupported providers fail classified)
187
+ ace-git pr ready 25 --expected-head SHA
188
+
189
+ # Merge with provider-side expected-head enforcement; no default method
190
+ ace-git pr merge 25 --expected-head SHA --method squash
174
191
  ```
175
192
 
176
- **Options:**
177
- - `--format, -f` - Output format: `markdown` (default), `json`
178
- - `--with-diff` - Include PR diff in output
179
-
180
- **PR Number Formats:**
181
- - Simple number: `123`
182
- - Qualified: `owner/repo#456`
183
- - GitHub URL: `https://github.com/owner/repo/pull/789`
184
-
185
- **Requirements:** GitHub CLI (`gh`) must be installed and authenticated.
186
-
187
- ```bash
188
- # Install gh
189
- brew install gh
190
-
191
- # Authenticate
192
- gh auth login
193
- ```
193
+ **Behavior guarantees:**
194
+ - Create is idempotent: one exact open base/head match returns the existing
195
+ PR (`idempotency: existing`); multiple matches are a conflict.
196
+ - `merge` requires the provider to enforce `--expected-head` atomically;
197
+ providers without that capability return an unsupported-capability failure
198
+ instead of racing.
199
+ - A create request whose outcome is unknown (transport failure after send)
200
+ reports an unknown outcome with the exact identity to reconcile; it never
201
+ retries automatically.
194
202
 
195
203
  ### `ace-git version`
196
204
 
@@ -267,11 +275,18 @@ Note: `test/**/*` and `spec/**/*` are NOT excluded by default - test changes are
267
275
  - **ace-bundle** - Load ACE workflow instructions directly
268
276
  - **ace-nav** - Discover workflow and template protocol paths
269
277
 
270
- ## Reusable GitHub Issue Primitives
278
+ ## Provider Packages
271
279
 
272
- `ace-git` also exposes reusable library primitives for issue synchronization flows used by other ACE packages:
280
+ `ace-git` is forge-neutral. It defines the server registry (`Ace::Git::ServerRegistry`),
281
+ the provider contract (`Ace::Git::Providers::Base`), and normalized evidence types;
282
+ provider packages own all forge CLI behavior:
273
283
 
274
- - `Ace::Git::Molecules::GhCliExecutor` - shared `gh` command execution with timeout and auth/install error handling
275
- - `Ace::Git::Molecules::GithubIssueSync.sync_task(...)` - sticky comment upsert, `ace:tracked` label management, and close/reopen lifecycle actions
284
+ - **ace-git-github** - GitHub behavior via the `gh` CLI (PR lookups, diffs, issue
285
+ synchronization). Registers under the `github` provider type.
286
+ - **ace-git-forgejo** - Forgejo behavior via the `fj` CLI. Registers under the
287
+ `forgejo` provider type.
276
288
 
277
- These are intended for package integrations such as `ace-task` and are not invoked directly through `ace-git` CLI commands.
289
+ Configure servers in `.ace/git.yml` (or project config) under `git.servers`, then
290
+ resolve them by name, by default, or from a git remote URL. Every failure path is
291
+ classified (missing CLI, unauthenticated, unreachable, malformed output, object
292
+ not found) with no silent fallbacks.
@@ -106,7 +106,7 @@ module Ace
106
106
  # Using Timeout to prevent hanging on network issues or stuck git operations
107
107
  Timeout.timeout(timeout) do
108
108
  # Using Open3.capture3 to avoid shell injection
109
- # Arguments are passed directly as array elements, not through shell
109
+ # Arguments are passed directly as array elements, never via a shell
110
110
  # If env is provided, prepend it to the command (Open3 convention)
111
111
  args = env ? [env, *command_parts] : command_parts
112
112
  stdout, stderr, status = Open3.capture3(*args)
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Git
5
+ module Atoms
6
+ # Parse provider-neutral pull request identifiers into structured form.
7
+ #
8
+ # Supports the forge-neutral forms:
9
+ # - Simple number: "123"
10
+ # - Qualified reference: "owner/repo#456"
11
+ #
12
+ # Provider-specific identifier formats (for example full web URLs) belong
13
+ # to the provider packages, not to the core.
14
+ module PrIdentifier
15
+ # Parsed identifier result
16
+ ParseResult = Data.define(:number, :repo)
17
+
18
+ # Maximum identifier length to bound regex work
19
+ MAX_IDENTIFIER_LENGTH = 256
20
+
21
+ # Parse a pull request identifier string.
22
+ #
23
+ # Returns nil for nil/empty input (no PR specified), raises ArgumentError
24
+ # for invalid formats. This lets callers distinguish "no PR" (nil input)
25
+ # from "invalid PR" (bad format).
26
+ #
27
+ # @param input [String, Integer] pull request identifier
28
+ # @return [ParseResult, nil] parsed identifier, or nil for nil/empty input
29
+ # @raise [ArgumentError] if identifier format is invalid
30
+ #
31
+ # @example Simple number
32
+ # parse(123) # => ParseResult(number: "123", repo: nil)
33
+ #
34
+ # @example Qualified reference
35
+ # parse("owner/repo#456") # => ParseResult(number: "456", repo: "owner/repo")
36
+ #
37
+ # @example Nil/empty input
38
+ # parse(nil) # => nil
39
+ def self.parse(input)
40
+ return nil if input.nil?
41
+
42
+ input_str = input.to_s.strip
43
+ return nil if input_str.empty?
44
+
45
+ if input_str.length > MAX_IDENTIFIER_LENGTH
46
+ raise ArgumentError, "PR identifier too long (max #{MAX_IDENTIFIER_LENGTH} characters)"
47
+ end
48
+
49
+ case input_str
50
+ when /\A(\d+)\z/
51
+ number = ::Regexp.last_match(1)
52
+ raise ArgumentError, "Invalid PR identifier format: #{input_str}" if number.to_i.zero?
53
+
54
+ # Canonical form (no leading zeros) for consistent downstream use
55
+ ParseResult.new(number: number.to_i.to_s, repo: nil)
56
+ when /\A(?<repo>[a-zA-Z0-9_\-.]+\/[a-zA-Z0-9_\-.]+)#(?<number>\d+)\z/
57
+ match = ::Regexp.last_match
58
+ ParseResult.new(number: match[:number], repo: match[:repo])
59
+ else
60
+ raise ArgumentError, "Invalid PR identifier format: #{input_str}"
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "server_url"
4
+
5
+ module Ace
6
+ module Git
7
+ module Atoms
8
+ # Parse a user-supplied pull request identifier into its exact components.
9
+ #
10
+ # Three accepted shapes:
11
+ # - bare number: "25"
12
+ # - owner/repo#number: "cs3b/ace#25"
13
+ # - full URL: "https://forge.example.com/cs3b/ace/pull/25"
14
+ # "https://forge.example.com/cs3b/ace/pulls/25"
15
+ #
16
+ # A URL's repository part is canonicalized through {ServerUrl.normalize}
17
+ # so identity matching never assumes a hostname. Parsing is purely
18
+ # textual; it never contacts a forge and never infers github.com.
19
+ module PrReference
20
+ # Parsed reference: `number` always present; exactly one of
21
+ # `repository_url` (canonical URL form) or `owner_repo` is set for
22
+ # explicit-repository shapes, both nil for a bare number.
23
+ Reference = Data.define(:number, :repository_url, :owner_repo) do
24
+ # @return [Boolean] true when the reference names a repository
25
+ def repository_explicit?
26
+ !repository_url.nil? || !owner_repo.nil?
27
+ end
28
+
29
+ # @return [Hash] plain representation
30
+ def to_h
31
+ {number: number, repository_url: repository_url, owner_repo: owner_repo}
32
+ end
33
+ end
34
+
35
+ BARE_NUMBER_PATTERN = /\A\d+\z/
36
+ OWNER_REPO_PATTERN = /\A(?<owner>[\w.\-]+)\/(?<repo>[\w.\-]+)#(?<number>\d+)\z/
37
+ URL_PATTERN = %r{
38
+ \Ahttps?://[^/\s]+/(?<path>.+?)/pulls?/(?<number>\d+)(?:[/?#].*)?\z
39
+ }ix
40
+
41
+ class << self
42
+ # Parse a pull request identifier.
43
+ #
44
+ # @param input [String, Integer] identifier in any accepted shape
45
+ # @return [Reference, nil] parsed reference, or nil when unrecognized
46
+ def parse(input)
47
+ text = input.to_s.strip
48
+ return nil if text.empty?
49
+
50
+ if (match = text.match(BARE_NUMBER_PATTERN))
51
+ return Reference.new(number: match[0].to_i, repository_url: nil, owner_repo: nil)
52
+ end
53
+
54
+ if (match = text.match(OWNER_REPO_PATTERN))
55
+ return Reference.new(
56
+ number: match[:number].to_i,
57
+ repository_url: nil,
58
+ owner_repo: "#{match[:owner]}/#{match[:repo]}"
59
+ )
60
+ end
61
+
62
+ if (match = text.match(URL_PATTERN))
63
+ return Reference.new(
64
+ number: match[:number].to_i,
65
+ repository_url: ServerUrl.normalize("#{text.split("/")[0..2].join("/")}/#{match[:path]}"),
66
+ owner_repo: nil
67
+ )
68
+ end
69
+
70
+ nil
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Git
5
+ module Atoms
6
+ # Normalize forge server URLs and remote URLs to a canonical comparable
7
+ # form. Purely textual: no network access, no hostname allow-lists, and
8
+ # no assumptions about any particular forge host.
9
+ #
10
+ # Supported input shapes:
11
+ # - "https://forge.example.com/owner/repo.git"
12
+ # - "http://forge.internal:3000/owner/repo"
13
+ # - "git@forge.example.com:owner/repo.git" (scp-style)
14
+ # - "ssh://git@forge.example.com:2222/owner/repo.git"
15
+ # - "forge.example.com/owner/repo"
16
+ #
17
+ # Canonical form: lowercase "host[:port]/path" with any credentials,
18
+ # scheme, trailing slash, and ".git" suffix removed. A colon segment is
19
+ # treated as a port (not an scp separator) only when it is all digits and
20
+ # followed by a slash; everything else is scp-style "host:path".
21
+ module ServerUrl
22
+ # Normalize a server or remote URL to the canonical comparable form.
23
+ #
24
+ # @param url [String] server or remote URL
25
+ # @return [String] canonical "host[:port]/path" form
26
+ def self.normalize(url)
27
+ text = url.to_s.strip
28
+ text = text.sub(%r{\A[a-z][a-z0-9+.\-]*://}i, "")
29
+ text = text.sub(%r{\A[^/@]+@}, "")
30
+
31
+ if (port_match = text.match(%r{\A([^/:]+):(\d+)(/.+)\z}))
32
+ text = "#{port_match[1]}:#{port_match[2]}#{port_match[3]}"
33
+ elsif (scp_match = text.match(%r{\A([^/:]+):(.+)\z}))
34
+ text = "#{scp_match[1]}/#{scp_match[2]}"
35
+ end
36
+
37
+ text = text.sub(%r{/?\.git/?\z}i, "")
38
+ text.chomp("/").downcase
39
+ end
40
+
41
+ # Compare a configured server URL against a remote URL for identity.
42
+ #
43
+ # @param server_url [String] configured server base URL
44
+ # @param remote_url [String] git remote URL
45
+ # @return [Boolean] true when both normalize to the same form
46
+ def self.match?(server_url, remote_url)
47
+ normalized_server = normalize(server_url)
48
+ normalized_remote = normalize(remote_url)
49
+ return false if normalized_server.empty? || normalized_remote.empty?
50
+
51
+ normalized_server == normalized_remote
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -75,40 +75,37 @@ module Ace
75
75
  lines
76
76
  end
77
77
 
78
- # Format current PR section (highlighted for current branch)
79
- # @param pr_metadata [Hash] PR metadata
78
+ # Format current PR section (spotlit for current branch)
79
+ # @param pr [ProviderPullRequest, nil] normalized pull request evidence
80
80
  # @return [Array<String>] Lines of markdown
81
- def format_current_pr_section(pr_metadata)
81
+ def format_current_pr_section(pr)
82
82
  lines = []
83
83
  lines << ""
84
84
  lines << "## Current PR"
85
85
  lines << ""
86
86
 
87
- # Main line: #85 [OPEN] Title
88
- main_line = "##{pr_metadata["number"]}"
89
- main_line += " [#{pr_metadata["state"]}]" if pr_metadata["state"]
90
- main_line += " #{pr_metadata["title"]}" if pr_metadata["title"]
87
+ # Main line: #85 [open] Title
88
+ main_line = "##{pr.number}"
89
+ main_line += " [#{pr.state}]" if pr.state
90
+ main_line += " #{pr.title}" if pr.title
91
91
  lines << main_line
92
92
 
93
93
  # Details line: Target: main | Author: @username | Draft/Not draft
94
94
  details = []
95
- details << "Target: #{pr_metadata["baseRefName"]}" if pr_metadata["baseRefName"]
96
- if pr_metadata["author"]
97
- author = pr_metadata.dig("author", "login") || pr_metadata["author"]
98
- details << "Author: @#{author}"
99
- end
100
- details << (pr_metadata["isDraft"] ? "Draft" : "Not draft") if pr_metadata.key?("isDraft")
95
+ details << "Target: #{pr.base_ref}" if pr.base_ref
96
+ details << "Author: @#{pr.author}" if pr.author
97
+ details << (pr.draft ? "Draft" : "Not draft") unless pr.draft.nil?
101
98
  lines << " #{details.join(" | ")}" unless details.empty?
102
99
 
103
100
  # URL line
104
- lines << " #{pr_metadata["url"]}" if pr_metadata["url"]
101
+ lines << " #{pr.url}" if pr.url
105
102
 
106
103
  lines
107
104
  end
108
105
 
109
106
  # Format PR activity section
110
- # @param pr_activity [Hash, nil] PR activity with :merged and :open arrays
111
- # Each PR in the arrays has string keys from JSON parsing: "number", "title", etc.
107
+ # @param pr_activity [Hash, nil] Activity with :merged and :open arrays of
108
+ # normalized ProviderPullRequest evidence
112
109
  # @return [Array<String>] Lines of markdown
113
110
  def format_pr_activity_section(pr_activity)
114
111
  lines = []
@@ -119,17 +116,15 @@ module Ace
119
116
  # Handle nil pr_activity for defensive programming
120
117
  return lines << "No recent PR activity" if pr_activity.nil?
121
118
 
122
- # pr_activity uses symbol keys (from RepoStatusLoader)
123
- # PR data within uses string keys (from JSON parsing)
124
119
  merged = pr_activity[:merged] || []
125
120
  open_prs = pr_activity[:open] || []
126
121
 
127
122
  unless merged.empty?
128
123
  lines << "Merged:"
129
124
  merged.each do |pr|
130
- title = pr["title"] || "(no title)"
131
- merged_ago = format_merged_time_compact(pr["mergedAt"])
132
- lines << " ##{pr["number"]} #{title}#{merged_ago}"
125
+ title = pr.title || "(no title)"
126
+ merged_ago = format_merged_time_compact(pr.merged_at)
127
+ lines << " ##{pr.number} #{title}#{merged_ago}"
133
128
  end
134
129
  end
135
130
 
@@ -137,9 +132,9 @@ module Ace
137
132
  lines << "" unless merged.empty? # Add spacing between Merged and Open
138
133
  lines << "Open:"
139
134
  open_prs.each do |pr|
140
- title = pr["title"] || "(no title)"
141
- author = format_author(pr["author"])
142
- lines << " ##{pr["number"]} #{title}#{author}"
135
+ title = pr.title || "(no title)"
136
+ author = format_author(pr.author)
137
+ lines << " ##{pr.number} #{title}#{author}"
143
138
  end
144
139
  end
145
140
 
@@ -163,13 +158,13 @@ module Ace
163
158
  end
164
159
 
165
160
  # Format author info
166
- # @param author [Hash, String, nil] Author data
161
+ # @param author [String, nil] Author identifier
167
162
  # @return [String] Formatted string like " (@username)"
168
163
  def format_author(author)
169
164
  return "" if author.nil?
170
165
 
171
- login = author.is_a?(Hash) ? author["login"] : author.to_s
172
- return "" if login.nil? || login.empty?
166
+ login = author.to_s
167
+ return "" if login.empty?
173
168
 
174
169
  " (@#{login})"
175
170
  end