ace-git-forgejo 0.4.0 → 0.6.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: e1e1e605a8808db94b0b68ad90d445149f8fce01df65f7ddad401f59a0fce255
4
- data.tar.gz: 312685879a60d54363626b6f7dc98d2954b0575120aeb0dde0e128826252fba6
3
+ metadata.gz: cf55e32720105f61ee776ab2e828cc52eff70422d40169c3ec473faa8918852e
4
+ data.tar.gz: d53c30cfdd65d9efd401846871c8572a48442c68d280a0615e7d9b04c8792b45
5
5
  SHA512:
6
- metadata.gz: 85a2d43f44c7d07d7c60c236f9a5c81c350233fca510c4a6503181f42ab2052a50e31094380e1a2ac752a55bb3ecdaf6923376a439197c459c1a71a66ed21364
7
- data.tar.gz: ddfee8db45e1545dfb7592e77396255a71d90e083c455a1507ee412da71482fee2a499c098c47ed59cbcc0b6abe46952cea8faa5b151d97b2521106c5e2f6bc8
6
+ metadata.gz: c3d888812cbbec6e223ddfca72514bbee53f3281374825b4147c4f1ebc3fd415528f4fc5c08eafadb6cfed35df6acac4220955f629e15b90dc593d91ceee963c
7
+ data.tar.gz: 9dd8b2e6fed777a0c038b111e448c536c0a960c7a7ff53e6d0d9ca1e200bd083e591cb35bf6d7943ec552e206b006021a7314b9bb615e93a9df1a34e179c1f98
data/CHANGELOG.md CHANGED
@@ -7,13 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.6.0] - 2026-10-04
11
+
12
+ ### Added
13
+ - Complete PR delivery lifecycle over the repository-bound Forgejo API v1: create with canonical and same-server fork heads and exact draft state, mark-ready, and merge with the server-enforced `head_commit_id` expected-head precondition (minimum supported server: Forgejo 8.0 — first line enforcing the merge precondition and reporting the API draft field — probed before mutations).
14
+ - Authoritative pull request reads from the API: exact head provenance, draft state, and merge-commit evidence replace the fj view/commits pair for PR identity.
15
+ - Duplicate-create races reconcile to the exact open match; already-merged refusals are reusable only with authoritative merged evidence at the expected source SHA.
16
+
17
+ ### Changed
18
+ - PR reads, lifecycle matching, and updates ride the Faraday HTTP transport (ADR-010) with retries limited to safe GETs and no redirect following; lifecycle mutations are gated on a documented server capability floor and fail closed without it.
19
+
20
+ ### Removed
21
+ - The fj-era refusals for fork creation, draft-to-ready, and expected-head merging; the conservative `fj pr merge`/`pr edit` paths are replaced by the API lifecycle.
22
+
23
+ ## [0.5.0] - 2026-10-03
24
+
25
+ ### Added
26
+ - Forgejo issue tracking through the shared provider contract with selected-server authority checks and parsed issue evidence.
27
+
28
+ ### Fixed
29
+ - Missing tracking labels are created on demand before attachment, forbidden organization-label listings fall back to repository labels, and fj credentials are found in the real macOS bundle directories (forgejo-cli.forgejo-cli, legacy Cyborus.forgejo-cli).
30
+
10
31
  ## [0.4.0] - 2026-10-02
11
32
 
12
33
  ### Added
13
34
  - Repository-bound Forgejo PR comment and review retrieval and comment create/update through the shared provider contract.
14
35
  - Correlation-based reconciliation after uncertain posts and explicit unsupported thread resolution.
15
36
 
16
-
17
37
  ## [0.3.0] - 2026-09-29
18
38
 
19
39
  ### Added
@@ -68,19 +88,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
68
88
  precondition atomically, so both refuse instead of racing.
69
89
  - PR view parser captures the fork repository prefix of the `From` segment.
70
90
 
71
- ### Added
72
- - PR lifecycle mutations matching the shared provider contract: exact-match
73
- lookup via `fj pr search`, idempotent create reconciliation, head-verified
74
- title/body updates.
75
- - Fork provenance: the `fj pr view` head-repository segment is parsed and
76
- returned in normalized PR evidence.
77
-
78
- ### Changed
79
- - `pr ready` and `pr merge` are classified unsupported capabilities: `fj`
80
- offers no draft-to-ready command and cannot enforce an expected-head merge
81
- precondition atomically, so both refuse instead of racing.
82
- - PR view parser captures the fork repository prefix of the `From` segment.
83
-
84
91
  ## [0.1.1] - 2026-09-28
85
92
 
86
93
 
data/README.md CHANGED
@@ -36,16 +36,15 @@ forbidden; there is no silent fallback of any kind.
36
36
 
37
37
  ## Supported operations (observed on forgejo-cli v0.6.0)
38
38
 
39
- PR view/diff/head-commits, PR search (all/open, client-side newest-first
40
- window — `fj` has no `--limit`), PR create (same-repository head only),
41
- PR edit title/body, issue view, actions tasks, repository view, version
42
- and `auth list` probes. `fj` v0.6.0 exposes no draft-to-ready command, no
43
- atomic expected-head merge, and no authoritative merge-commit field, so
44
- `ready_pull_request`/`merge_pull_request` refuse as unsupported
45
- capabilities and `merge_commit_sha` stays empty — worktree cleanup
46
- conservatively retains Forgejo checkouts until `fj` exposes that field.
47
- The capability evidence and its provenance live in the
48
- `8wr.t.uj0` task folder (`evidence/fj-capabilities.md`).
39
+ Through `fj` (observed on forgejo-cli v0.6.0): PR view/diff/head-commits,
40
+ PR search (all/open, client-side newest-first window — `fj` has no
41
+ `--limit`), issue view, actions tasks, repository view, version and
42
+ `auth list` probes. Through the repository-bound API v1: authoritative PR
43
+ reads, PR create (canonical and same-server fork heads, exact draft
44
+ state), edit, ready transitions, and expected-head-guarded merges — see
45
+ the delivery lifecycle section below. The per-surface capability evidence
46
+ and its provenance live in the `8wr.t.uj0` (`evidence/fj-capabilities.md`)
47
+ and `8x2.t.z78` (`capability-evidence-2026-10-04.md`) task folders.
49
48
 
50
49
  Endpoint fidelity: `-H` carries the selected `scheme://authority` (fj
51
50
  otherwise assumes HTTPS), and because fj v0.6.0 silently applies its
@@ -54,3 +53,46 @@ observed keys-file locations and refuses a repository command when an
54
53
  alias redirects the selected host — never modifying user fj
55
54
  configuration; an absent or unreadable keys file is allowed.
56
55
 
56
+ ## PR delivery lifecycle (Forgejo API v1)
57
+
58
+ The delivery lifecycle — create (canonical and same-server fork sources,
59
+ exact draft state), mark-ready, and merge with a server-enforced
60
+ expected-head precondition — rides the repository-bound Forgejo REST API
61
+ v1 through the same selected host and `fj` token as the review transport.
62
+ The `fj` v0.6 CLI cannot encode a fork head, an explicit draft state, a
63
+ ready transition, or an atomic merge precondition, so those operations do
64
+ not approximate it with check-then-act sequencing.
65
+
66
+ Minimum supported server: **Forgejo 8.0** (the first line that both
67
+ enforces the documented `MergePullRequestOption.head_commit_id` merge
68
+ precondition server-side and reports the API `draft` field; the provider
69
+ probes `/api/v1/version` and refuses lifecycle mutations below the
70
+ floor). Official capability evidence lives in the
71
+ `8x2.t.z78` task folder (`capability-evidence-2026-10-04.md`).
72
+
73
+ Forgejo-specific conventions the provider translates:
74
+
75
+ - **Drafts**: the create/edit API forms have no writable draft field —
76
+ a draft is a WIP-prefixed title (server defaults `WIP:`, `[WIP]`;
77
+ compared case-insensitively). The server reports the computed boolean
78
+ in the API `draft` response field, which this provider treats as the
79
+ only draft truth on reads. Create with
80
+ `draft: true` prefixes the title `WIP: `; `draft: false` refuses a
81
+ WIP-prefixed title instead of silently publishing it as a draft. Ready
82
+ strips one leading prefix and proves the resulting `draft: false` by
83
+ read-back. A draft whose title carries no known prefix refuses before
84
+ the mutation (the server likely uses configured prefixes).
85
+ - **Merge** sends the full expected SHA in `head_commit_id`; Forgejo
86
+ re-resolves the head ref inside the merge and refuses with 409 when it
87
+ moved. The provider classifies that as
88
+ `ProviderExpectedHeadConflictError` and never pre-reads as a substitute
89
+ guard. A 409 that reads back as already merged at the expected source
90
+ SHA, with a merge commit, is reusable authoritative evidence.
91
+ - **Reconciliation**: a transport failure on any mutation raises
92
+ `ProviderUnknownOutcomeError` carrying the exact identity; nothing is
93
+ retried automatically. A duplicate-create 409 reconciles to the exact
94
+ open match; mutation outcomes are proven by authoritative read-backs
95
+ (head unchanged, requested state, merged evidence).
96
+
97
+ Identical lifecycle assertions run against every provider through the
98
+ shared `Ace::TestSupport::PullRequestLifecycleContract` parity suite.
@@ -2,9 +2,9 @@
2
2
 
3
3
  require "digest"
4
4
  require "json"
5
- require "net/http"
5
+ require "faraday"
6
+ require "faraday/retry"
6
7
  require "openssl"
7
- require "uri"
8
8
  require_relative "repository_binding"
9
9
 
10
10
  module Ace
@@ -12,6 +12,13 @@ module Ace
12
12
  module Forgejo
13
13
  # Authenticated, repository-bound JSON transport for Forgejo API
14
14
  # capabilities whose fj v0.6 output omits stable object IDs.
15
+ #
16
+ # Live traffic uses Faraday (ADR-010): no redirect middleware is
17
+ # installed (a redirect must never carry credentials or a write to
18
+ # another host; the configured endpoint is verified up front by
19
+ # {RepositoryBinding.reject_selected_host_redirect!}), and retries are
20
+ # limited to the safe GET verb — a mutation is sent at most once per
21
+ # call, so a lost response stays an unknown outcome.
15
22
  class HttpClient
16
23
  def initialize(server:, timeout:, runner: nil)
17
24
  @target = RepositoryBinding::Target.resolve(server.url)
@@ -44,40 +51,97 @@ module Ace
44
51
  entries
45
52
  end
46
53
 
54
+ # Send one request and classify every non-success status with the
55
+ # shared taxonomy.
56
+ #
57
+ # @return [Object, nil] parsed JSON body (nil when empty)
47
58
  def request(method, suffix, body: nil)
59
+ status, payload = exchange(method, suffix, body: body)
60
+ classify_status!(status, method, exchange_path(suffix))
61
+ payload
62
+ end
63
+
64
+ # Send one request and return the raw HTTP outcome so endpoint-aware
65
+ # callers can classify statuses the generic map cannot distinguish
66
+ # (e.g. a 409 that may mean "already exists" on create or "head out
67
+ # of date" on merge). Transport failures still classify by method:
68
+ # reads are unreachable, mutations are unknown outcomes.
69
+ #
70
+ # @return [Array(Integer, Object, nil)] status, parsed JSON body
71
+ # (nil when empty), and the server-supplied error message for
72
+ # non-success statuses (sanitized, truncated)
73
+ def exchange(method, suffix, body: nil)
48
74
  RepositoryBinding.reject_selected_host_redirect!(@target.host_url)
49
- path = "/api/v1/repos/#{@target.repo}/#{suffix}"
75
+ path = exchange_path(suffix)
50
76
  if @runner
51
77
  result = @runner.call(args: ["forgejo-http", method.to_s.upcase, "#{@target.host_url}#{path}", body],
52
78
  timeout: @timeout, env: {})
53
79
  status = result[:status] || (result[:success] ? 200 : 503)
54
80
  payload = result[:stdout].to_s
55
81
  else
56
- uri = URI.parse("#{@target.host_url}#{path}")
57
- klass = {get: Net::HTTP::Get, post: Net::HTTP::Post, patch: Net::HTTP::Patch}.fetch(method)
58
- req = klass.new(uri)
59
- req["Authorization"] = "token #{access_token!}"
60
- req["Accept"] = "application/json"
61
- req["Content-Type"] = "application/json" if body
62
- req.body = JSON.generate(body) if body
63
- response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
64
- open_timeout: @timeout, read_timeout: @timeout) { |http| http.request(req) }
65
- status = response.code.to_i
82
+ response = connection(method).send(method, path) do |req|
83
+ req.headers["Content-Type"] = "application/json" if body
84
+ req.body = JSON.generate(body) if body
85
+ end
86
+ status = response.status
66
87
  payload = response.body.to_s
67
88
  end
68
- classify_status!(status, method, path)
69
- return nil if payload.empty?
70
-
71
- JSON.parse(payload)
72
- rescue JSON::ParserError => e
73
- raise Ace::Git::ProviderMalformedOutputError, "Malformed Forgejo response: #{e.message}"
74
- rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout, IOError, SocketError, OpenSSL::SSL::SSLError, SystemCallError => e
89
+ [status, parse_payload(payload), server_error_message(payload)]
90
+ rescue Faraday::TimeoutError, Faraday::ConnectionFailed, IOError, SocketError,
91
+ OpenSSL::SSL::SSLError, SystemCallError => e
75
92
  error = (method == :get) ? Ace::Git::ProviderUnreachableError : Ace::Git::ProviderUnknownOutcomeError
76
93
  raise error, "Forgejo #{method} #{path} outcome unknown: #{e.class}"
77
94
  end
78
95
 
79
96
  private
80
97
 
98
+ # Suffixes already carrying the API root are used verbatim (the
99
+ # server-level /api/v1/version probe); everything else is
100
+ # repository-scoped to the selected owner/repo.
101
+ def exchange_path(suffix)
102
+ return suffix if suffix.to_s.start_with?("/api/v1/")
103
+
104
+ "/api/v1/repos/#{@target.repo}/#{suffix}"
105
+ end
106
+
107
+ # A non-JSON body is an absent payload, not a parse failure: the
108
+ # status carries the outcome, and every consumer validates the shape
109
+ # of what it reads, so malformed evidence still fails closed.
110
+ def parse_payload(payload)
111
+ return nil if payload.nil? || payload.empty?
112
+
113
+ JSON.parse(payload)
114
+ rescue JSON::ParserError
115
+ nil
116
+ end
117
+
118
+ # The forge's own explanation for a failed status, sanitized: only
119
+ # the JSON `message` string survives, truncated, so tokens and raw
120
+ # response bodies never reach an error message.
121
+ def server_error_message(payload)
122
+ parsed = parse_payload(payload)
123
+ return nil unless parsed.is_a?(Hash) && parsed["message"].is_a?(String)
124
+
125
+ message = parsed["message"].strip
126
+ message.empty? ? nil : message[0, 300]
127
+ end
128
+
129
+ # Connection per HTTP method: the retry middleware is installed only
130
+ # for the safe verb so a mutation can never be silently re-sent.
131
+ def connection(method)
132
+ @connections = {} unless @connections.is_a?(Hash)
133
+ @connections[method] ||= Faraday.new(url: @target.host_url) do |faraday|
134
+ faraday.options.timeout = @timeout
135
+ faraday.options.open_timeout = @timeout
136
+ faraday.headers["Accept"] = "application/json"
137
+ faraday.headers["Authorization"] = "token #{access_token!}"
138
+ if method == :get
139
+ faraday.request :retry, max: 2, methods: [:get], retry_if: ->(_env, _result) { false }
140
+ end
141
+ faraday.adapter Faraday.default_adapter
142
+ end
143
+ end
144
+
81
145
  def access_token!
82
146
  path = RepositoryBinding.default_keys_path
83
147
  data = path && JSON.parse(File.read(path))
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "openssl"
6
+ require "uri"
7
+ require_relative "repository_binding"
8
+ require "ace/git/atoms/server_url"
9
+
10
+ module Ace
11
+ module Git
12
+ module Forgejo
13
+ # Structured issue operations unavailable through fj's output contract.
14
+ # Uses the same exact selected host and the token from fj's keys file.
15
+ class IssueApi
16
+ def initialize(server:, timeout:, runner: nil)
17
+ # Clone URLs may be SSH-style; the HTTP issue client always talks
18
+ # to the verified HTTPS web endpoint derived from that identity.
19
+ @target = RepositoryBinding::Target.resolve(Ace::Git::Atoms::ServerUrl.web_base(server.url))
20
+ @timeout = timeout
21
+ @runner = runner
22
+ end
23
+
24
+ def issue(number)
25
+ request(:get, "issues/#{number}")
26
+ end
27
+
28
+ def comments(number)
29
+ paginate("issues/#{number}/comments")
30
+ end
31
+
32
+ def create_comment(number, body)
33
+ request(:post, "issues/#{number}/comments", body: {body: body})
34
+ end
35
+
36
+ def update_comment(comment_id, body)
37
+ request(:patch, "issues/comments/#{comment_id}", body: {body: body})
38
+ end
39
+
40
+ def delete_comment(comment_id)
41
+ request(:delete, "issues/comments/#{comment_id}")
42
+ end
43
+
44
+ def add_label(number, label_id)
45
+ request(:post, "issues/#{number}/labels", body: {labels: [label_id]})
46
+ end
47
+
48
+ def remove_label(number, label_id)
49
+ request(:delete, "issues/#{number}/labels/#{label_id}")
50
+ end
51
+
52
+ def repository_labels(wanted: nil)
53
+ labels = paginate("labels")
54
+ # Repository labels satisfy the common case; the organization label
55
+ # listing is an extra request that can fail (403 org scope) and must
56
+ # not block linking when the wanted label is already available.
57
+ return labels if wanted && labels.any? { |entry| entry["name"] == wanted }
58
+
59
+ owner = @target.repo.split("/", 2).first
60
+ begin
61
+ labels + paginate("/api/v1/orgs/#{owner}/labels")
62
+ rescue Ace::Git::ProviderObjectNotFoundError, Ace::Git::ProviderAuthenticationError
63
+ # Organization labels are an optional supplement: 404 means no org
64
+ # scope exists and 401/403 mean the token cannot read it. Both
65
+ # leave the repository labels authoritative; errors from the
66
+ # repository-label request above still propagate.
67
+ labels
68
+ end
69
+ end
70
+
71
+ def create_repo_label(name:, color:)
72
+ request(:post, "labels", body: {name: name, color: color})
73
+ end
74
+
75
+ def set_state(number, state)
76
+ request(:patch, "issues/#{number}", body: {state: state.to_s})
77
+ end
78
+
79
+ private
80
+
81
+ def paginate(suffix)
82
+ page = 1
83
+ entries = []
84
+ seen = []
85
+ loop do
86
+ batch = request(:get, "#{suffix}?page=#{page}&limit=50")
87
+ unless batch.is_a?(Array)
88
+ raise Ace::Git::ProviderMalformedOutputError, "Forgejo #{suffix} did not return a list"
89
+ end
90
+ before = entries.length
91
+ # Stop when a page adds no new items (unique ids): instances
92
+ # may cap pages below the requested limit, so a short page alone
93
+ # does not prove the end of the collection.
94
+ fresh = batch.reject { |item| seen.include?(item["id"]) }
95
+ entries.concat(fresh)
96
+ fresh.each { |item| seen << item["id"] }
97
+ break if fresh.empty?
98
+
99
+ page += 1
100
+ end
101
+ entries
102
+ end
103
+
104
+ def request(method, suffix, body: nil)
105
+ RepositoryBinding.reject_selected_host_redirect!(@target.host_url)
106
+ path = suffix.start_with?("/api/v1/") ? suffix : "/api/v1/repos/#{@target.repo}/#{suffix}"
107
+ if @runner
108
+ result = @runner.call(args: ["forgejo-http", method.to_s.upcase, "#{@target.host_url}#{path}", body],
109
+ timeout: @timeout, env: {})
110
+ status = result[:status] || (result[:success] ? 200 : 503)
111
+ payload = result[:stdout].to_s
112
+ else
113
+ uri = URI.parse("#{@target.host_url}#{path}")
114
+ token = access_token!
115
+ klass = {get: Net::HTTP::Get, post: Net::HTTP::Post, patch: Net::HTTP::Patch,
116
+ delete: Net::HTTP::Delete}.fetch(method)
117
+ req = klass.new(uri)
118
+ req["Authorization"] = "token #{token}"
119
+ req["Accept"] = "application/json"
120
+ req["Content-Type"] = "application/json" if body
121
+ req.body = JSON.generate(body) if body
122
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
123
+ open_timeout: @timeout, read_timeout: @timeout) { |http| http.request(req) }
124
+ status = response.code.to_i
125
+ payload = response.body.to_s
126
+ end
127
+ classify_status!(status, method, path)
128
+ return nil if payload.empty?
129
+
130
+ JSON.parse(payload)
131
+ rescue JSON::ParserError => e
132
+ raise Ace::Git::ProviderMalformedOutputError, "Malformed Forgejo issue response: #{e.message}"
133
+ rescue Net::OpenTimeout, Net::ReadTimeout, IOError, SocketError, SystemCallError,
134
+ OpenSSL::SSL::SSLError => e
135
+ error = method == :get ? Ace::Git::ProviderUnreachableError : Ace::Git::ProviderUnknownOutcomeError
136
+ raise error, "Forgejo issue #{method} #{path} outcome unknown: #{e.class}"
137
+ end
138
+
139
+ def access_token!
140
+ path = RepositoryBinding.default_keys_path
141
+ data = path && JSON.parse(File.read(path))
142
+ login = data.is_a?(Hash) && data.fetch("hosts", {})[@target.authority]
143
+ token = login.is_a?(Hash) && login["token"]
144
+ return token if token.is_a?(String) && !token.empty?
145
+
146
+ raise Ace::Git::ProviderAuthenticationError,
147
+ "No fj token for selected host #{@target.host_url}; authenticate with fj first"
148
+ rescue Errno::ENOENT, Errno::EACCES, JSON::ParserError
149
+ raise Ace::Git::ProviderAuthenticationError,
150
+ "Cannot read fj authentication for selected host #{@target.host_url}"
151
+ end
152
+
153
+ def classify_status!(status, method, path)
154
+ return if (200..299).cover?(status.to_i)
155
+
156
+ error = case status.to_i
157
+ when 401, 403 then Ace::Git::ProviderAuthenticationError
158
+ when 404 then Ace::Git::ProviderObjectNotFoundError
159
+ # A conflict on a mutation does not prove ownership was rejected
160
+ # before the write: earlier requests in the same sync may already
161
+ # have committed (e.g. the tracking comment). Uncertainty keeps the
162
+ # local recovery identity instead of deleting the task.
163
+ when 409, 422 then method == :get ? Ace::Git::ProviderIdentityMismatchError : Ace::Git::ProviderUnknownOutcomeError
164
+ else method == :get ? Ace::Git::ProviderUnreachableError : Ace::Git::ProviderUnknownOutcomeError
165
+ end
166
+ raise error, "Forgejo issue #{method} #{path} returned HTTP #{status}"
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end
@@ -119,19 +119,31 @@ module Ace
119
119
  tasks = []
120
120
  declared_count = nil
121
121
  text.to_s.lines.map { |line| clean(line) }.reject(&:empty?).each do |line|
122
- # `fj` emits a leading "<n> tasks" count header before task lines.
122
+ # `fj` emits exactly one leading "<n> tasks" count header
123
+ # before task lines; require it (including 0 tasks).
123
124
  if (header = line.match(/\A(\d+) tasks?\z/i))
125
+ raise Ace::Git::ProviderMalformedOutputError,
126
+ "Duplicate `fj actions tasks` count header" if declared_count
124
127
  declared_count = header[1].to_i
125
128
  next
126
129
  end
130
+ raise Ace::Git::ProviderMalformedOutputError,
131
+ "Missing `fj actions tasks` count header" unless declared_count
127
132
 
128
133
  match = line.match(
129
134
  /\A#(?<task>\d+)\s+\((?<sha>[0-9a-f]+)\)\s+(?<state>\w+)\s+(?<name>.+?)\s+[\dhms.]+\s+\((?<event>[^)]+)\)/
130
135
  )
131
136
  # Any other nonempty line that cannot be parsed must not silently
132
- # shrink check evidence; fail closed instead.
137
+ # shrink check evidence; fail closed instead. The SHA may be
138
+ # abbreviated; the provider binds it to the reviewed head by
139
+ # prefix, so anything shorter than Git's conventional minimum
140
+ # abbreviation cannot establish commit identity and is rejected.
133
141
  raise Ace::Git::ProviderMalformedOutputError,
134
142
  "Unrecognized `fj actions tasks` output line: #{line[0, 80]}" unless match
143
+ unless match[:sha].length.between?(7, 40)
144
+ raise Ace::Git::ProviderMalformedOutputError,
145
+ "Unbindable `fj actions tasks` SHA (need 7-40 hex chars): #{match[:sha]}"
146
+ end
135
147
 
136
148
  tasks << {
137
149
  name: match[:name],
@@ -139,10 +151,15 @@ module Ace
139
151
  sha: match[:sha]
140
152
  }
141
153
  end
154
+ # Empty or whitespace-only output never reaches the in-loop header
155
+ # check; the contract requires the count header even for zero tasks,
156
+ # so fail closed instead of returning an empty task list.
157
+ raise Ace::Git::ProviderMalformedOutputError,
158
+ "Missing `fj actions tasks` count header" unless declared_count
142
159
  # A declared count that disagrees with the parsed rows means
143
160
  # truncated output; fail closed instead of accepting partial
144
161
  # check evidence.
145
- if declared_count && declared_count != tasks.length
162
+ if declared_count != tasks.length
146
163
  raise Ace::Git::ProviderMalformedOutputError,
147
164
  "Incomplete `fj actions tasks` output: declared #{declared_count}, parsed #{tasks.length}"
148
165
  end