diff --git a/.cspell/dicts/project.txt b/.cspell/dicts/project.txt index e80891e..617094f 100644 --- a/.cspell/dicts/project.txt +++ b/.cspell/dicts/project.txt @@ -35,3 +35,8 @@ burndown splitn hexdigit PRRT +gantt +sattg +varname +CMTS +DOTS diff --git a/.gitignore b/.gitignore index 7626fee..ad7c4fa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target .claude/worktrees +smoke-test-result.txt diff --git a/mise.toml b/mise.toml index 56a6456..22958a6 100644 --- a/mise.toml +++ b/mise.toml @@ -110,6 +110,11 @@ description = "Run Rust tests" run = ["cargo test -- --nocapture"] alias = "rt" +[tasks.rs-run] +description = "Run Rust application" +run = "cargo run" +alias = "rr" + [tasks.rs-build] description = "Build Rust application" run = "cargo build" diff --git a/smoke-test.sh b/smoke-test.sh new file mode 100755 index 0000000..d2e1d46 --- /dev/null +++ b/smoke-test.sh @@ -0,0 +1,236 @@ +#!/usr/bin/env bash +# Smoke test: run all read-only bl commands against a real Backlog space. +# Usage: ./smoke-test.sh [SPACE_KEY [PROJECT_KEY]] +set -euo pipefail + +SPACE_KEY="${1:-sattg}" +PROJECT_KEY="${2:-DOTS_TEST}" +BL="${BL:-$(command -v bl 2>/dev/null || echo "./target/debug/bl")}" +PASS=0 +FAIL=0 +BL_OUT=$(mktemp) +trap 'rm -f "$BL_OUT"' EXIT + +# ── helpers ──────────────────────────────────────────────────────────────────── + +run() { + local label="$1"; shift + if "$BL" --space "$SPACE_KEY" "$@" > "$BL_OUT" 2>&1; then + echo " PASS $label" + PASS=$((PASS + 1)) + else + echo " FAIL $label" + sed 's/^/ /' "$BL_OUT" + FAIL=$((FAIL + 1)) + fi +} + +# Run command and capture stdout for later use; still reports PASS/FAIL. +run_capture() { + local label="$1"; local varname="$2"; shift 2 + local output + if "$BL" --space "$SPACE_KEY" "$@" > "$BL_OUT" 2>&1; then + echo " PASS $label" + PASS=$((PASS + 1)) + output="$(cat "$BL_OUT")" + printf -v "$varname" '%s' "$output" + else + echo " FAIL $label" + sed 's/^/ /' "$BL_OUT" + FAIL=$((FAIL + 1)) + printf -v "$varname" '' + fi +} + +section() { echo; echo "── $* ──────────────────────────────────────────"; } + +jq_or_empty() { + # Extract a value with jq; return empty string on error. + local input="$1"; local query="$2" + echo "$input" | jq -r "$query" 2>/dev/null || true +} + +# ── Phase 1: space (read-only) ───────────────────────────────────────────────── + +section "auth" +run "auth status" auth status + +section "space" +run "space show" space --json +run "space activities" space activities --json +run "space disk-usage" space disk-usage --json +run "space notification" space notification --json +run "space licence" space licence --json + +section "master data" +run "priority list" priority list --json +run "resolution list" resolution list --json +run "rate-limit" rate-limit --json + +# ── Phase 2: project ────────────────────────────────────────────────────────── + +section "project" +run "project list" project list --json +run_capture "project show" PROJ_JSON project show "$PROJECT_KEY" --json +run "project activities" project activities "$PROJECT_KEY" --json +run "project disk-usage" project disk-usage "$PROJECT_KEY" --json +run "project user list" project user list "$PROJECT_KEY" --json +run "project admin list" project admin list "$PROJECT_KEY" --json +run "project status list" project status list "$PROJECT_KEY" --json +run "project issue-type list" project issue-type list "$PROJECT_KEY" --json +run "project category list" project category list "$PROJECT_KEY" --json +run "project version list" project version list "$PROJECT_KEY" --json +run "project team list" project team list "$PROJECT_KEY" --json +run "project webhook list" project webhook list "$PROJECT_KEY" --json +run "project custom-field list" project custom-field list "$PROJECT_KEY" --json + +PROJECT_ID=$(jq_or_empty "$PROJ_JSON" '.id') + +# ── Phase 3: issues ─────────────────────────────────────────────────────────── + +section "issue" +if [[ -n "$PROJECT_ID" ]]; then + run "issue list" issue list --project-id "$PROJECT_ID" --json + run "issue count" issue count --project-id "$PROJECT_ID" --json + run_capture "issue show (first)" ISSUE_JSON issue list --project-id "$PROJECT_ID" --count 1 --json + ISSUE_KEY=$(jq_or_empty "$ISSUE_JSON" '.[0].issueKey // empty') +else + echo " SKIP issue list/count (project ID unavailable)" + ISSUE_KEY="" +fi + +if [[ -n "$ISSUE_KEY" ]]; then + run "issue show $ISSUE_KEY" issue show "$ISSUE_KEY" --json + run "issue comment list" issue comment list "$ISSUE_KEY" --json + run "issue comment count" issue comment count "$ISSUE_KEY" --json + run "issue attachment list" issue attachment list "$ISSUE_KEY" --json + run "issue participant list" issue participant list "$ISSUE_KEY" --json + run "issue shared-file list" issue shared-file list "$ISSUE_KEY" --json + + # comment show (first comment if any) + run_capture "issue comment list (for id)" CMTS_JSON issue comment list "$ISSUE_KEY" --json + COMMENT_ID=$(jq_or_empty "$CMTS_JSON" '.[0].id // empty') + if [[ -n "$COMMENT_ID" ]]; then + run "issue comment show $COMMENT_ID" issue comment show "$ISSUE_KEY" "$COMMENT_ID" --json + run "issue comment notification list" issue comment notification list "$ISSUE_KEY" "$COMMENT_ID" --json + else + echo " SKIP issue comment show/notification (no comments)" + fi +else + echo " SKIP issue subcommands (no issues found)" +fi + +# ── Phase 4: wiki ───────────────────────────────────────────────────────────── + +section "wiki" +run "wiki list" wiki list "$PROJECT_KEY" --json +run "wiki count" wiki count "$PROJECT_KEY" --json +run "wiki tag list" wiki tag list "$PROJECT_KEY" --json + +run_capture "wiki list (for id)" WIKIS_JSON wiki list "$PROJECT_KEY" --json +WIKI_ID=$(jq_or_empty "$WIKIS_JSON" '.[0].id // empty') + +if [[ -n "$WIKI_ID" ]]; then + run "wiki show $WIKI_ID" wiki show "$WIKI_ID" --json + run "wiki history" wiki history "$WIKI_ID" --json + run "wiki attachment list" wiki attachment list "$WIKI_ID" --json + run "wiki shared-file list" wiki shared-file list "$WIKI_ID" --json + run "wiki star list" wiki star list "$WIKI_ID" --json +else + echo " SKIP wiki show/history/attachment/shared-file/star (no wiki pages)" +fi + +# ── Phase 5: documents ──────────────────────────────────────────────────────── + +section "document" +if [[ -n "$PROJECT_ID" ]]; then + run "document list" document list --project-id "$PROJECT_ID" --json + run "document tree" document tree "$PROJECT_KEY" --json + + run_capture "document list (for id)" DOCS_JSON document list --project-id "$PROJECT_ID" --json + DOC_ID=$(jq_or_empty "$DOCS_JSON" '.list[0].id // .[0].id // empty') + if [[ -n "$DOC_ID" ]]; then + run "document show $DOC_ID" document show "$DOC_ID" --json + else + echo " SKIP document show (no documents)" + fi +else + echo " SKIP document (project ID unavailable)" +fi + +# ── Phase 6: shared files ───────────────────────────────────────────────────── + +section "shared-file" +run "shared-file list" shared-file list "$PROJECT_KEY" --json + +# ── Phase 7: git / PR ───────────────────────────────────────────────────────── + +section "git / pr" +run_capture "git repo list" REPOS_JSON git repo list "$PROJECT_KEY" --json +REPO_NAME=$(jq_or_empty "$REPOS_JSON" '.[0].name // empty') + +if [[ -n "$REPO_NAME" ]]; then + run "git repo show" git repo show "$PROJECT_KEY" "$REPO_NAME" --json + run "pr list" pr list "$PROJECT_KEY" "$REPO_NAME" --json + run "pr count" pr count "$PROJECT_KEY" "$REPO_NAME" --json + + run_capture "pr list (for number)" PRS_JSON pr list "$PROJECT_KEY" "$REPO_NAME" --json + PR_NUM=$(jq_or_empty "$PRS_JSON" '.[0].number // empty') + if [[ -n "$PR_NUM" ]]; then + run "pr show $PR_NUM" pr show "$PROJECT_KEY" "$REPO_NAME" "$PR_NUM" --json + run "pr comment list" pr comment list "$PROJECT_KEY" "$REPO_NAME" "$PR_NUM" --json + run "pr comment count" pr comment count "$PROJECT_KEY" "$REPO_NAME" "$PR_NUM" --json + run "pr attachment list" pr attachment list "$PROJECT_KEY" "$REPO_NAME" "$PR_NUM" --json + else + echo " SKIP pr show/comment/attachment (no pull requests)" + fi +else + echo " SKIP git repo show / pr (no git repositories)" +fi + +# ── Phase 8: user ───────────────────────────────────────────────────────────── + +section "user" +run "user list" user list --json +run "user recently-viewed" user recently-viewed --json +run "user recently-viewed-projects" user recently-viewed-projects --json +run "user recently-viewed-wikis" user recently-viewed-wikis --json + +run_capture "user list (for id)" USERS_JSON user list --json +USER_ID=$(jq_or_empty "$USERS_JSON" '.[] | select(.userId != null) | .id' | head -1) + +if [[ -n "$USER_ID" ]]; then + run "user show $USER_ID" user show "$USER_ID" --json + run "user activities" user activities "$USER_ID" --json + run "user star list" user star list "$USER_ID" --json + run "user star count" user star count "$USER_ID" --json + run "watch list" watch list "$USER_ID" --json + run "watch count" watch count "$USER_ID" --json +else + echo " SKIP user show/activities/star/watch (no users with userId)" +fi + +# ── Phase 9: team (space scope, read-only) ──────────────────────────────────── + +section "team" +run_capture "team list" TEAMS_JSON team list --json +TEAM_ID=$(jq_or_empty "$TEAMS_JSON" '.[0].id // empty') +if [[ -n "$TEAM_ID" ]]; then + run "team show $TEAM_ID" team show "$TEAM_ID" --json +else + echo " SKIP team show (no teams)" +fi + +# ── Phase 10: notification (space scope, read-only) ─────────────────────────── + +section "notification" +run "notification list" notification list --json +run "notification count" notification count --json + +# ── summary ─────────────────────────────────────────────────────────────────── + +echo +echo "════════════════════════════════════════" +echo " PASS: $PASS FAIL: $FAIL" +echo "════════════════════════════════════════" +[[ "$FAIL" -eq 0 ]] diff --git a/src/api/licence.rs b/src/api/licence.rs index 2f6b232..eef860d 100644 --- a/src/api/licence.rs +++ b/src/api/licence.rs @@ -8,10 +8,10 @@ use super::deserialize; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Licence { - pub start_date: String, + pub start_date: Option, pub contract_type: Option, - pub storage_limit: u64, - pub storage_usage: u64, + pub storage_limit: Option, + pub storage_usage: Option, #[serde(flatten)] pub extra: BTreeMap, } @@ -50,10 +50,27 @@ mod tests { let client = BacklogClient::new_with(&server.base_url(), "test-key").unwrap(); let l = client.get_space_licence().unwrap(); - assert_eq!(l.start_date, "2020-01-01"); + assert_eq!(l.start_date, Some("2020-01-01".to_string())); assert_eq!(l.contract_type, Some("premium".to_string())); - assert_eq!(l.storage_limit, 1073741824); - assert_eq!(l.storage_usage, 5242880); + assert_eq!(l.storage_limit, Some(1073741824)); + assert_eq!(l.storage_usage, Some(5242880)); + } + + #[test] + fn get_space_licence_without_start_date() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/space/licence"); + then.status(200).json_body(json!({ + "contractType": "premium", + "storageLimit": 1073741824u64 + })); + }); + + let client = BacklogClient::new_with(&server.base_url(), "test-key").unwrap(); + let l = client.get_space_licence().unwrap(); + assert_eq!(l.start_date, None); + assert_eq!(l.storage_usage, None); } #[test] diff --git a/src/api/rate_limit.rs b/src/api/rate_limit.rs index 2d21251..308ebe0 100644 --- a/src/api/rate_limit.rs +++ b/src/api/rate_limit.rs @@ -6,12 +6,21 @@ use super::deserialize; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct RateLimitInfo { +pub struct RateLimitCategory { pub limit: u64, pub remaining: u64, pub reset: u64, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RateLimitInfo { + pub read: RateLimitCategory, + pub update: RateLimitCategory, + pub search: RateLimitCategory, + pub icon: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RateLimit { @@ -34,9 +43,10 @@ mod tests { fn rate_limit_json() -> serde_json::Value { json!({ "rateLimit": { - "limit": 600, - "remaining": 599, - "reset": 1698230400 + "read": {"limit": 600, "remaining": 591, "reset": 1774268714}, + "update": {"limit": 150, "remaining": 150, "reset": 1774268655}, + "search": {"limit": 150, "remaining": 150, "reset": 1774268655}, + "icon": {"limit": 60, "remaining": 60, "reset": 1774268655} } }) } @@ -51,9 +61,30 @@ mod tests { let client = BacklogClient::new_with(&server.base_url(), "test-key").unwrap(); let rl = client.get_rate_limit().unwrap(); - assert_eq!(rl.rate_limit.limit, 600); - assert_eq!(rl.rate_limit.remaining, 599); - assert_eq!(rl.rate_limit.reset, 1698230400); + assert_eq!(rl.rate_limit.read.limit, 600); + assert_eq!(rl.rate_limit.read.remaining, 591); + assert_eq!(rl.rate_limit.update.limit, 150); + assert_eq!(rl.rate_limit.search.limit, 150); + assert_eq!(rl.rate_limit.icon.unwrap().limit, 60); + } + + #[test] + fn get_rate_limit_without_icon() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/rateLimit"); + then.status(200).json_body(json!({ + "rateLimit": { + "read": {"limit": 600, "remaining": 600, "reset": 1774268714}, + "update": {"limit": 150, "remaining": 150, "reset": 1774268655}, + "search": {"limit": 150, "remaining": 150, "reset": 1774268655} + } + })); + }); + + let client = BacklogClient::new_with(&server.base_url(), "test-key").unwrap(); + let rl = client.get_rate_limit().unwrap(); + assert!(rl.rate_limit.icon.is_none()); } #[test] diff --git a/src/cmd/rate_limit.rs b/src/cmd/rate_limit.rs index 3de0c0e..e485db0 100644 --- a/src/cmd/rate_limit.rs +++ b/src/cmd/rate_limit.rs @@ -29,16 +29,34 @@ pub fn show_with(args: &RateLimitArgs, api: &dyn BacklogApi) -> Result<()> { } fn format_rate_limit_text(rl: &RateLimit) -> String { - format!( - "Limit: {}\nRemaining: {}\nReset: {}", - rl.rate_limit.limit, rl.rate_limit.remaining, rl.rate_limit.reset, - ) + let info = &rl.rate_limit; + let mut out = format!( + "Read: limit={}, remaining={}, reset={}\n\ + Update: limit={}, remaining={}, reset={}\n\ + Search: limit={}, remaining={}, reset={}", + info.read.limit, + info.read.remaining, + info.read.reset, + info.update.limit, + info.update.remaining, + info.update.reset, + info.search.limit, + info.search.remaining, + info.search.reset, + ); + if let Some(icon) = &info.icon { + out.push_str(&format!( + "\nIcon: limit={}, remaining={}, reset={}", + icon.limit, icon.remaining, icon.reset + )); + } + out } #[cfg(test)] mod tests { use super::*; - use crate::api::rate_limit::RateLimitInfo; + use crate::api::rate_limit::{RateLimitCategory, RateLimitInfo}; use anyhow::anyhow; struct MockApi { @@ -56,9 +74,26 @@ mod tests { fn sample_rate_limit() -> RateLimit { RateLimit { rate_limit: RateLimitInfo { - limit: 600, - remaining: 599, - reset: 1698230400, + read: RateLimitCategory { + limit: 600, + remaining: 591, + reset: 1774268714, + }, + update: RateLimitCategory { + limit: 150, + remaining: 150, + reset: 1774268655, + }, + search: RateLimitCategory { + limit: 150, + remaining: 150, + reset: 1774268655, + }, + icon: Some(RateLimitCategory { + limit: 60, + remaining: 60, + reset: 1774268655, + }), }, } } @@ -87,10 +122,21 @@ mod tests { } #[test] - fn format_rate_limit_text_contains_all_fields() { + fn format_rate_limit_text_contains_all_categories() { let text = format_rate_limit_text(&sample_rate_limit()); + assert!(text.contains("Read:")); + assert!(text.contains("Update:")); + assert!(text.contains("Search:")); + assert!(text.contains("Icon:")); assert!(text.contains("600")); - assert!(text.contains("599")); - assert!(text.contains("1698230400")); + assert!(text.contains("591")); + } + + #[test] + fn format_rate_limit_text_without_icon() { + let mut rl = sample_rate_limit(); + rl.rate_limit.icon = None; + let text = format_rate_limit_text(&rl); + assert!(!text.contains("Icon:")); } } diff --git a/src/cmd/space/licence.rs b/src/cmd/space/licence.rs index 7269d8d..e17a978 100644 --- a/src/cmd/space/licence.rs +++ b/src/cmd/space/licence.rs @@ -32,10 +32,13 @@ pub fn licence_with(args: &SpaceLicenceArgs, api: &dyn BacklogApi) -> Result<()> fn format_licence_text(l: &Licence) -> String { let contract = l.contract_type.as_deref().unwrap_or("(not set)"); - format!( - "Contract: {}\nStorage: {} / {} bytes\nStart: {}", - contract, l.storage_usage, l.storage_limit, l.start_date - ) + let start = l.start_date.as_deref().unwrap_or("(not set)"); + let storage = match (l.storage_usage, l.storage_limit) { + (Some(usage), Some(limit)) => format!("{usage} / {limit} bytes"), + (None, Some(limit)) => format!("(unknown) / {limit} bytes"), + _ => "(not set)".to_string(), + }; + format!("Contract: {contract}\nStorage: {storage}\nStart: {start}") } #[cfg(test)] @@ -56,10 +59,10 @@ mod tests { fn sample_licence() -> Licence { Licence { - start_date: "2020-01-01".to_string(), + start_date: Some("2020-01-01".to_string()), contract_type: Some("premium".to_string()), - storage_limit: 1073741824, - storage_usage: 5242880, + storage_limit: Some(1073741824), + storage_usage: Some(5242880), extra: BTreeMap::new(), } } @@ -99,13 +102,27 @@ mod tests { #[test] fn format_licence_text_with_null_contract_type() { let l = Licence { - start_date: "2020-01-01".to_string(), + start_date: Some("2020-01-01".to_string()), contract_type: None, - storage_limit: 1073741824, - storage_usage: 0, + storage_limit: Some(1073741824), + storage_usage: Some(0), extra: BTreeMap::new(), }; let text = format_licence_text(&l); assert!(text.contains("(not set)")); } + + #[test] + fn format_licence_text_with_null_start_date() { + let l = Licence { + start_date: None, + contract_type: Some("premium".to_string()), + storage_limit: Some(1073741824), + storage_usage: None, + extra: BTreeMap::new(), + }; + let text = format_licence_text(&l); + assert!(text.contains("(not set)")); + assert!(text.contains("(unknown)")); + } } diff --git a/website/docs/commands.md b/website/docs/commands.md index e2b7f02..c80833b 100644 --- a/website/docs/commands.md +++ b/website/docs/commands.md @@ -134,6 +134,15 @@ Storage: 5242880 / 1073741824 bytes Start: 2020-01-01 ``` +Some fields (`startDate`, `storageUsage`) may be absent depending on the Backlog plan. +In that case those fields are shown as `(not set)` or `(unknown)`: + +```text +Contract: (not set) +Storage: (unknown) / 107374182400 bytes +Start: (not set) +``` + ## `bl space update-notification` Update the notification message set for your Backlog space. @@ -2232,11 +2241,14 @@ bl rate-limit --json Example output: ```text -Limit: 600 -Remaining: 599 -Reset: 1698230400 +Read: limit=600, remaining=599, reset=1698230400 +Update: limit=150, remaining=150, reset=1698230400 +Search: limit=150, remaining=150, reset=1698230400 +Icon: limit=60, remaining=60, reset=1698230400 ``` +The `Icon` row is only shown when the Backlog plan includes the icon rate limit category. + ## Command coverage The table below maps Backlog API v2 endpoints to `bl` commands. diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/commands.md b/website/i18n/ja/docusaurus-plugin-content-docs/current/commands.md index 6c7f3e2..010094e 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/commands.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/commands.md @@ -133,6 +133,15 @@ Storage: 5242880 / 1073741824 bytes Start: 2020-01-01 ``` +Backlog プランによっては `startDate` や `storageUsage` が API レスポンスに含まれない場合があります。 +その場合、該当フィールドは `(not set)` または `(unknown)` として表示されます: + +```text +Contract: (not set) +Storage: (unknown) / 107374182400 bytes +Start: (not set) +``` + ## `bl space update-notification` Backlog スペースの通知メッセージを更新します。 @@ -2234,11 +2243,14 @@ bl rate-limit --json 出力例: ```text -Limit: 600 -Remaining: 599 -Reset: 1698230400 +Read: limit=600, remaining=599, reset=1698230400 +Update: limit=150, remaining=150, reset=1698230400 +Search: limit=150, remaining=150, reset=1698230400 +Icon: limit=60, remaining=60, reset=1698230400 ``` +`Icon` 行は Backlog プランにアイコンのレート制限カテゴリが含まれる場合のみ表示されます。 + ## コマンドカバレッジ Backlog API v2 エンドポイントと `bl` コマンドの対応表です。