Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added
- Bitbucket Pipelines logs: `prw checks --logs`, `prw wait --logs` and the MCP tool `get_failed_logs` show, for each failed Pipelines build, the output of the script command that failed in its first failed step (from the last `+ command` line up to the teardown), or the reason Bitbucket gives when there's no log (e.g. a timeout). Needs a token with the pipeline read scope.
- Auto-watch on Bitbucket Cloud: `prw watch --mine` / `--review-requested` and `prw run` also find your Bitbucket PRs (when a Bitbucket token or `bitbucket_user` is set): your open PRs in every workspace (or `bitbucket_workspaces`), and open PRs listing you as a reviewer that you haven't approved or requested changes on, in repositories active in the last 30 days (rescanned every 15 minutes). Without a GitHub token, auto-watch works on Bitbucket alone.

### Changed
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,7 @@ Runs superseded by a newer run of the same check (e.g. cancelled by `concurrency

### Why did it fail? `--logs`

`prw checks --logs` (and `prw wait --logs`) also print, for each failed GitHub Actions job, the output of the step that failed: its last lines up to the error, with timestamps, colors and runner markup stripped — what you'd scroll to in the browser.
`prw checks --logs` (and `prw wait --logs`) also print, for each failed GitHub Actions job or Bitbucket Pipelines build, the output of the step that failed: its last lines up to the error, with timestamps, colors and runner markup stripped — what you'd scroll to in the browser.

```
$ prw checks https://github.com/owner/repo/pull/123 --logs
Expand Down Expand Up @@ -383,7 +383,7 @@ Tools:
| Tool | What it does |
|------|--------------|
| `get_ci_status` | Current state of a PR (`success`, `failure`, `error`, `pending`, `merged`, `closed`, `no_ci`), check counts, failed checks with links. Returns immediately. |
| `get_failed_logs` | For each failed GitHub Actions job, the output of the step that failed (last `max_lines` lines up to the error, default 40). What an agent needs to fix the failure without a browser. |
| `get_failed_logs` | For each failed GitHub Actions job or Bitbucket Pipelines build, the output of the step that failed (last `max_lines` lines up to the error, default 40). What an agent needs to fix the failure without a browser. |
| `get_flaky_checks` | A repository's jobs that failed and then passed on re-run of the same commit, with counts and links — so an agent can tell a known-flaky failure from one its change caused. |
| `wait_for_ci` | Blocks until CI finishes (`timeout_seconds` default 600, max 3600). Fails fast on the first failed check, follows new pushes, sends progress notifications. On timeout it returns `pending` with `timed_out: true` so the agent can call it again. |
| `list_watched` | PRs on the local watch list with their last known state. |
Expand All @@ -406,7 +406,8 @@ prw checks https://bitbucket.org/acme/api/pull-requests/42
- `prw run` notifies about Bitbucket PRs just like GitHub ones, `prw list` shows them as `bitbucket:workspace/repo`, and the MCP tools accept their URLs.
- **Auto-watch**: `prw watch --mine` follows your open PRs in all your workspaces (or those in `bitbucket_workspaces`). `--review-requested` follows open PRs that list you as a reviewer and that you haven't approved or requested changes on yet; Bitbucket can only search those per repository, so prw scans the repositories active in the last 30 days (up to 60 per workspace) every 15 minutes.
- Bitbucket Cloud allows 1,000 API requests per hour, so `prw run` spaces out the checks of Bitbucket PRs whatever the poll interval: each is checked at most once a minute, and less often as the list grows (every 18 seconds × the number of Bitbucket PRs, e.g. every 9 minutes with 30), so checks use about 600 requests an hour and leave room for auto-watch and other commands. A rate limit on one service doesn't pause the other.
- Not yet for Bitbucket: `--logs` and `prw flaky`.
- **`--logs`** shows, for each failed Pipelines build, the output of the script command that failed in its first failed step (reading Pipelines logs needs a token with the pipeline read scope; without it, prw shows the reason Bitbucket gives, e.g. a timeout). Builds from other CI systems (Jenkins, ...) are listed with their link.
- Not yet for Bitbucket: `prw flaky`.

**Authentication.** Public repositories work without credentials. For private ones, create an [Atlassian API token](https://id.atlassian.com/manage-profile/security/api-tokens) with Bitbucket read scopes (repositories and pull requests) and give prw your Atlassian email with it:

Expand Down
1 change: 1 addition & 0 deletions TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ Legend — Impact: 1–5 · Effort: S (≤½ day), M (1–2 days), L (3+ days)
- 🆕 Bitbucket Cloud support (requested by the owner, who uses Bitbucket at work). Phase 1 done: PR identity carries the host; a Bitbucket client maps PRs, build statuses (Pipelines, Jenkins), participants and merge checks onto the existing types, so run / wait / checks / list / broadcast / MCP work unchanged; per-host rate-limit pauses; ≥60s between checks of a Bitbucket PR (1,000 req/h limit). Verified live on public atlassian/atlassian-connect-express PRs. Next: auto-watch (`--mine` / `--review-requested`) on Bitbucket, Pipelines step logs for `--logs`, then GitHub Enterprise (#2) on the same host abstraction.
- ✅ `prw dashboard` (owner's use case: ~25 repos × dev/rc/master cherry-picks at work) — live table grouped by task across GitHub + Bitbucket, smart CI re-reads. Bitbucket discovery endpoints verified live anonymously (workspace user PRs, merged since, recent repos, reviewer filter); `/user` and `/user/workspaces` still need a check with the owner's token. Verified live on the owner's GitHub PRs.
- 🚀 v0.5.0 released with Bitbucket Cloud and `prw dashboard` (PRs #24–#25).
- ✅ Bitbucket Pipelines logs for `--logs` / MCP `get_failed_logs` (RunLogReader on the Bitbucket client: steps by build number, log by pipeline + step UUID). Verified live anonymously up to the log download (needs the pipeline scope; the timeout reason is shown instead); the log parser is tested on a constructed log, to check against a real one with the owner's token.
- ✅ Bitbucket auto-watch (`--mine` / `--review-requested`): per-host finders, review scan every 15 min skipping PRs already reviewed, check interval scaled to the number of Bitbucket PRs (600 req/h budget). Verified live with a public Bitbucket user + the owner's GitHub account in one watch list.
- Next: #17 packaging (Homebrew/Scoop) + keychain token storage, #8 `gh` extension publishing — both need owner decisions

Expand Down
6 changes: 3 additions & 3 deletions cmd/prw/checks.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ var (
func init() {
rootCmd.AddCommand(checksCmd)
checksCmd.Flags().BoolVar(&checksJSON, "json", false, "print the checks as JSON")
checksCmd.Flags().BoolVar(&checksLogs, "logs", false, "show the end of the log of each failed GitHub Actions job")
checksCmd.Flags().BoolVar(&checksLogs, "logs", false, "show the end of the log of each failed GitHub Actions job or Bitbucket Pipelines build")
checksCmd.Flags().IntVar(&checksLogLines, "log-lines", github.DefaultLogLines, "lines of each failed job's log to show with --logs")
}

Expand All @@ -53,8 +53,8 @@ commit statuses) of a pull request's head commit: failures first, then running
ones, then the rest. The PR does not need to be on the watch list.

With --logs, it also prints the output of the step that failed in each failed
GitHub Actions job (the last --log-lines lines up to the error), so you can see
why without opening the browser.
GitHub Actions job or Bitbucket Pipelines build (the last --log-lines lines up to
the error), so you can see why without opening the browser.

Exit codes:
0 all checks passed
Expand Down
3 changes: 2 additions & 1 deletion cmd/prw/logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ import (
// maxMCPLogLines caps the log lines per failed job returned to agents.
const maxMCPLogLines = 200

// fetchFailedLogs gets log excerpts of the failed GitHub Actions jobs of a commit.
// fetchFailedLogs gets log excerpts of the failed GitHub Actions jobs (or Bitbucket
// Pipelines builds) of a commit.
// Failed commit statuses (external CI) are listed too, with their link, since they
// have no log in the API.
func fetchFailedLogs(ctx context.Context, client ciClient, owner, repo, sha string, maxLines int) ([]github.FailedJobLog, error) {
Expand Down
2 changes: 1 addition & 1 deletion cmd/prw/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -469,7 +469,7 @@ var mcpTools = []map[string]interface{}{
{
"name": "get_failed_logs",
"title": "Get failed CI job logs",
"description": "For each failed GitHub Actions job on a pull request's head commit, return the output of the step that failed: its last lines up to the error, with timestamps and colors stripped. Use it after get_ci_status or wait_for_ci report a failure, to see why without opening the browser. Checks from other CI systems have no log here; their links are returned instead. At most 5 job logs are downloaded per call.",
"description": "For each failed GitHub Actions job or Bitbucket Pipelines build on a pull request's head commit, return the output of the step that failed: its last lines up to the error, with timestamps and colors stripped. Use it after get_ci_status or wait_for_ci report a failure, to see why without opening the browser. Checks from other CI systems have no log here; their links are returned instead. At most 5 job logs are downloaded per call.",
"inputSchema": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
Expand Down
7 changes: 4 additions & 3 deletions cmd/prw/wait.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ func init() {
waitCmd.Flags().DurationVar(&waitTimeout, "timeout", 30*time.Minute, "give up after this long (0 = wait forever)")
waitCmd.Flags().DurationVar(&waitInterval, "interval", 15*time.Second, "how often to poll GitHub")
waitCmd.Flags().BoolVar(&waitJSON, "json", false, "print the result as JSON on stdout")
waitCmd.Flags().BoolVar(&waitLogs, "logs", false, "on failure, also print the end of the log of each failed GitHub Actions job")
waitCmd.Flags().BoolVar(&waitLogs, "logs", false, "on failure, also print the end of the log of each failed GitHub Actions job or Bitbucket Pipelines build")
waitCmd.Flags().IntVar(&waitLogLines, "log-lines", github.DefaultLogLines, "lines of each failed job's log to show with --logs")
}

Expand All @@ -94,7 +94,7 @@ Exit codes:

Progress goes to stderr; the result goes to stdout (as JSON with --json). With
--logs, a failure also prints the output of the step that failed in each failed
GitHub Actions job (failed_logs in --json).`,
GitHub Actions job or Bitbucket Pipelines build (failed_logs in --json).`,
Example: ` prw wait https://github.com/owner/repo/pull/123
prw wait https://github.com/owner/repo/pull/123 --timeout 10m --json
prw wait https://github.com/owner/repo/pull/123 --logs
Expand Down Expand Up @@ -179,7 +179,8 @@ type waitResult struct {
SHA string `json:"sha,omitempty"`
State string `json:"state"` // success, failure, error, pending, merged, closed
FailedChecks []github.FailedCheck `json:"failed_checks,omitempty"`
// FailedLogs has the log excerpt of each failed GitHub Actions job (--logs).
// FailedLogs has the log excerpt of each failed GitHub Actions job or Pipelines
// build (--logs).
FailedLogs []github.FailedJobLog `json:"failed_logs,omitempty"`
TotalChecks int `json:"total_checks"`
PendingChecks int `json:"pending_checks"`
Expand Down
2 changes: 1 addition & 1 deletion internal/bitbucket/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,7 @@ func (c *Client) GetReviews(ctx context.Context, workspace, repo string, number
return reviews, nil
}

// GetJobLog is not available for Bitbucket yet.
// GetJobLog is GitHub Actions only; Pipelines logs are read with RunLog.
func (c *Client) GetJobLog(ctx context.Context, workspace, repo string, jobID int64) (string, error) {
return "", ErrUnsupported
}
Expand Down
173 changes: 173 additions & 0 deletions internal/bitbucket/pipelines.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
package bitbucket

import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"

"github.com/devblac/prw/internal/github"
)

// maxStepLogBytes is how much of the end of a step log is read: the failure is there.
const maxStepLogBytes = 4 << 20

var (
// pipelineResultURL matches the link of a Pipelines build status, e.g.
// https://bitbucket.org/ws/repo/addon/pipelines/home#!/results/2435 or
// https://bitbucket.org/ws/repo/pipelines/results/2435.
pipelineResultURL = regexp.MustCompile(`/pipelines/(?:home#!/)?results/(\d+)`)
ansiEscape = regexp.MustCompile(`\x1b\[[0-9;?]*[A-Za-z]`)
// teardownLine starts the output Pipelines adds after the script.
teardownLine = regexp.MustCompile(`^(Searching for test report files|Finished scanning for test reports|Merged test suites|Searching for files matching artifact pattern)`)
)

func pipelineBuildNumber(run github.CheckRun) (int, bool) {
m := pipelineResultURL.FindStringSubmatch(run.HTMLURL)
if m == nil {
return 0, false
}
n, err := strconv.Atoi(m[1])
return n, err == nil
}

// CanReadLog reports whether run is a Bitbucket Pipelines build, whose log prw reads.
func (c *Client) CanReadLog(run github.CheckRun) bool {
_, ok := pipelineBuildNumber(run)
return ok
}

// NoLogReason explains why other builds (Jenkins, ...) have no excerpt.
func (c *Client) NoLogReason() string {
return "not a Bitbucket Pipelines build; its log isn't available through the Bitbucket API"
}

type pipelineStep struct {
UUID string `json:"uuid"`
Name string `json:"name"`
State struct {
Result struct {
Name string `json:"name"` // SUCCESSFUL, FAILED, ERROR, STOPPED, NOT_RUN
Error struct {
Message string `json:"message"`
} `json:"error"`
} `json:"result"`
} `json:"state"`
Pipeline struct {
UUID string `json:"uuid"`
} `json:"pipeline"`
ScriptCommands []struct {
Name string `json:"name"`
} `json:"script_commands"`
}

// RunLog returns the output of the command that failed in the first failed step of a
// Pipelines build. Reading logs needs a token with the pipeline read scope.
func (c *Client) RunLog(ctx context.Context, workspace, repo string, run github.CheckRun, maxLines int) (github.LogExcerpt, error) {
number, ok := pipelineBuildNumber(run)
if !ok {
return github.LogExcerpt{}, errors.New(c.NoLogReason())
}
var resp struct {
Values []pipelineStep `json:"values"`
}
path := fmt.Sprintf("%s/pipelines/%d/steps/?pagelen=100", repoPath(workspace, repo), number)
if err := c.http.GetJSON(ctx, path, &resp); err != nil {
return github.LogExcerpt{}, fmt.Errorf("failed to list the steps of pipeline #%d: %w", number, err)
}
var step *pipelineStep
for i := range resp.Values {
switch strings.ToUpper(resp.Values[i].State.Result.Name) {
case "FAILED", "ERROR":
step = &resp.Values[i]
}
if step != nil {
break
}
}
if step == nil {
return github.LogExcerpt{}, fmt.Errorf("pipeline #%d has no failed step", number)
}
reason := step.State.Result.Error.Message // e.g. "Exceeded build time limit of 120 minutes."

logPath := fmt.Sprintf("%s/pipelines/%s/steps/%s/log", repoPath(workspace, repo), url.PathEscape(step.Pipeline.UUID), url.PathEscape(step.UUID))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Use the known pipeline identifier when constructing the log URL

For a steps response matching the documented Bitbucket API schema, the step has its own uuid but no embedded pipeline.uuid. Consequently, step.Pipeline.UUID remains empty and this constructs /pipelines//steps/{step}/log, preventing log retrieval even with the correct token scope. The new fixture supplies an embedded pipeline that masks this case. Reuse the pipeline identifier used to list the steps, or explicitly retrieve its UUID, rather than requiring it in each step.

Useful? React with 👍 / 👎.

log, err := c.http.GetText(ctx, logPath, maxStepLogBytes)
if err != nil {
var apiErr *github.APIError
if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound {
err = errors.New("log not available: reading Pipelines logs needs a Bitbucket token with the pipeline read scope, or the log expired")
}
if reason != "" {
return github.LogExcerpt{Step: step.Name, Lines: []string{reason}}, nil
}
return github.LogExcerpt{}, err
}
var commands []string
for _, cmd := range step.ScriptCommands {
commands = append(commands, cmd.Name)
}
excerpt := ExtractStepFailure(log, commands, maxLines)
if reason != "" {
excerpt.Lines = append(excerpt.Lines, reason)
}
return excerpt, nil
}

// ExtractStepFailure returns the output of the last script command a Pipelines step
// ran — the one that failed, since a step stops at the first failing command — up to
// the teardown, at most maxLines lines. Pipelines echoes each command as "+ <command>".
// Without such a line it returns the end of the log.
func ExtractStepFailure(log string, commands []string, maxLines int) github.LogExcerpt {
if maxLines <= 0 {
maxLines = github.DefaultLogLines
}
raw := strings.Split(strings.ReplaceAll(log, "\r\n", "\n"), "\n")
lines := make([]string, len(raw))
for i, l := range raw {
lines[i] = strings.TrimRight(ansiEscape.ReplaceAllString(l, ""), "\r")
}

known := map[string]bool{}
for _, c := range commands {
// A multi-line command is echoed with its first line.
first, _, _ := strings.Cut(c, "\n")
known[strings.TrimSpace(first)] = true
}
start, step := -1, ""
for i := len(lines) - 1; i >= 0 && start < 0; i-- {
if cmd, ok := strings.CutPrefix(lines[i], "+ "); ok && known[strings.TrimSpace(cmd)] {
start, step = i+1, strings.TrimSpace(cmd)
}
}
if start < 0 { // commands unknown or not echoed: fall back to any "+ " line
for i := len(lines) - 1; i >= 0 && start < 0; i-- {
if cmd, ok := strings.CutPrefix(lines[i], "+ "); ok {
start, step = i+1, strings.TrimSpace(cmd)
}
}
}
if start < 0 {
start = 0
}

end := len(lines)
for i := start; i < len(lines); i++ {
if teardownLine.MatchString(lines[i]) {
end = i
Comment on lines +157 to +160

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exclude after-script output before selecting the failure excerpt

For steps with after-script, Bitbucket runs those commands even when the main script fails. This extraction only stops at test-report or artifact-scanning messages, so cleanup output remains in the excerpt and sufficiently verbose cleanup pushes the actual failure out of the last maxLines lines. When command matching falls back to any + line, it can instead select a cleanup command outright. Identify the after-script boundary before selecting the failed command and trimming its output so --logs and get_failed_logs retain the actual failure.

Useful? React with 👍 / 👎.

break
}
}
for end > start && strings.TrimSpace(lines[end-1]) == "" {
end--
}
from := end - maxLines
if from < start {
from = start
}
out := append([]string{}, lines[from:end]...)
return github.LogExcerpt{Step: step, Lines: out, Truncated: from > start}
}
Loading
Loading