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
59 changes: 57 additions & 2 deletions .github/workflows/nightly-todo.yml
Original file line number Diff line number Diff line change
Expand Up @@ -152,10 +152,65 @@ jobs:
fi
# 削除は App token で行う。job の GITHUB_TOKEN は contents: read しか持たない
# (決定 8 § 副次効果 — agent が触れる唯一の資格情報を read-only に保つ)。
#
# **push は使い捨ての空リポジトリから行う。** checkout は 2 つとも `path:` 指定
# (master-ref / work) なので job の既定 cwd はリポジトリではなく、そこから
# `git push <url>` すると `fatal: not a git repository` (exit 128) で死ぬ
# (実測)。掃除対象が 1 件以上あった run は過去 40 回で 0 件のため、この経路は
# 一度も実行されておらず露見していなかった。checkout 側 (`git -C master-ref`)
# を使わないのは、actions/checkout が仕込む extraheader の資格情報が URL に
# 埋めた App token より優先されうるため — 空リポジトリなら継承する config が無い。
#
# 掃除対象の一覧は exe が別の ls-remote から作るため、そこから削除までの間に
# ref が動く / 消える窓がある。ここで守るのは 2 つ:
#
# 1. **他経路が push した作業を消さない**。観測した SHA を lease にして
# compare-and-delete する。ref が動いていれば削除は拒否される
# (実測: `! [rejected] (delete) -> X (stale info)` + exit 1 で ref は残る)。
# 2. **「既に消えている」で step 全体を落とさない** (決定 10: 設計された結末と
# インフラ障害を同じ扱いにしない)。ネットワーク / 認証エラーは従来どおり落とす。
#
# lease の失敗は「消えた」と「動いた」を**区別しない** (実測: どちらも
# `stale info`)。よって失敗したら ls-remote で分類する — git の文言ではなく
# ref の実在で判定するので、メッセージの変更に引きずられない。
PUSH_URL="https://x-access-token:${APP_TOKEN}@github.com/${{ github.repository }}.git"
# 出力に URL が混じっても token を露出させない (Actions の自動マスクへの多重防御)。
redact() { sed 's#x-access-token:[^@]*@#x-access-token:***@#g'; }
CLEANUP_REPO="$RUNNER_TEMP/cleanup-repo"
git init -q "$CLEANUP_REPO"
# ls-remote は URL 指定ならリポジトリ外でも動く (実測)。一致なしでも exit 0 +
# 空出力になるため、非 0 は「取得失敗」として落とす。
observe_ref() {
git ls-remote --heads "$PUSH_URL" "refs/heads/$1" 2>&1 | redact | head -n 1
}
while IFS= read -r branch; do
[ -n "$branch" ] || continue
git push "https://x-access-token:${APP_TOKEN}@github.com/${{ github.repository }}.git" \
--delete "refs/heads/$branch"
if ! OBSERVED=$(observe_ref "$branch"); then
printf '%s\n' "$OBSERVED" >&2
echo "[NIGHTLY] ref の存在確認に失敗: $branch" >&2
exit 1
fi
if [ -z "$OBSERVED" ]; then
echo "[NIGHTLY] 既に削除済みのため skip: $branch"
continue
fi
EXPECTED_SHA=$(printf '%s' "$OBSERVED" | cut -f1)
if ! DELETE_OUTPUT=$(git -C "$CLEANUP_REPO" push \
--force-with-lease="refs/heads/$branch:$EXPECTED_SHA" \
"$PUSH_URL" --delete "refs/heads/$branch" 2>&1 | redact); then
if ! RECHECK=$(observe_ref "$branch"); then
printf '%s\n' "$RECHECK" >&2
echo "[NIGHTLY] 削除失敗後の再確認に失敗: $branch" >&2
exit 1
fi
if [ -z "$RECHECK" ]; then
echo "[NIGHTLY] 削除直前に消えていたため skip: $branch"
continue
fi
printf '%s\n' "$DELETE_OUTPUT" >&2
echo "[NIGHTLY] 削除を中止: $branch (観測後に ref が動いた = 他経路の作業がある)" >&2
exit 1
fi
echo "[NIGHTLY] 削除: $branch"
done < "$RUNNER_TEMP/deletable-branches.txt"

Expand Down
8 changes: 8 additions & 0 deletions .takt/facets/instructions/aggregate-weekly.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,14 @@ category が複数該当する場合は最も特徴的な 1 つを採用、補

JSON は ADR-031 § Findings スキーマ準拠で `findings.json` というファイル名で write する (workflow の output contract では `name: findings.json` + `format: findings-json` として宣言されている — `findings.json` がファイル名、`findings-json` が契約 (format) 名)。

**`findings.json` は raw JSON のみを書くこと。ファイルの先頭は `{`、末尾は `}`。**
` ```json ` などのコードフェンスで囲んではならない。囲むと `/weekly-review` skill が
ファイルを JSON parser にそのまま渡せず、手作業の strip が要る (2026-05-30 dogfood で
実観測、todo 順位 181)。**この直後の JSON 例がフェンスの中にあるのは、この指示書を
読みやすくするためであって、出力の形ではない** — 例のフェンス行 (` ```json ` と
` ``` `) は書き出す内容に含めない。同じことが Phase 4 の Markdown report にも当てはまる
(report 本文をフェンスで包まない)。

> **`report_path` の所有権**: 下記 JSON 例の `report_path` field は **Phase C skill `/weekly-review` が copy 後の canonical location** を指す (`.claude/weekly-reviews/<date>.md`)。本 facet は `edit: false` のため自身で copy できないので、`report_path` field は将来 location を予告する形で記述する (= 「skill copy 後に存在する場所」を意味する forward-pointing 記述)。Phase C skill (`~/.claude/skills/weekly-review/SKILL.md`) Phase 2 が Report Directory から `.claude/weekly-reviews/<date>.md` に copy する責務を持つ。Phase C 未実装時 (= Phase B のみ稼働) は `report_path` は dead pointer になるが、Phase C skill が land した後は資源が realize される (PR #182 pre-push reviewer P-1 finding の Phase C 対応):

```json
Expand Down
18 changes: 17 additions & 1 deletion src/cli-stale-branch-scan/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,22 @@ mod collect;

use classify::{BranchVerdict, ClassifiedBranch};

/// gh のために `GIT_DIR` を注入する。
///
/// **`--repo` があるときは導出失敗を警告しない** (順位 467 F-2)。警告文は
/// 「gh の repo 解決が失敗しうる」という予測だが、`--repo <owner/name>` を渡していれば
/// gh は git からリポジトリを解決しないので予測が成り立たず、毎回のノイズにしかならない
/// (夜間 workflow は jj リポジトリ外で走るため実際に毎晩出ていた)。
///
/// 注入自体は `--repo` の有無に関わらず行う。`git ls-remote origin` のように **git 側が**
/// リポジトリを要る経路は独立に残るため、注入まで止めるとそちらを壊す。
fn inject_git_dir(cli: &Cli) {
lib_jj_helpers::inject_git_dir_for_gh_with(
|message| eprintln!("[stale-branch-scan] {message}"),
cli.repo.is_none(),
);
}

const USAGE: &str = "usage: cli-stale-branch-scan [--remote <name|url>] [--repo <owner/name>] \
[--prefix <p>] [--deletable-only]";
const DEFAULT_REMOTE: &str = "origin";
Expand Down Expand Up @@ -132,7 +148,7 @@ fn main() {
std::process::exit(2);
}
};
lib_jj_helpers::inject_git_dir_for_gh(|message| eprintln!("[stale-branch-scan] {message}"));
inject_git_dir(&cli);
let branches = match collect::fetch_remote_branches(&cli.remote) {
Ok(branches) => branches,
Err(message) => fail(&message),
Expand Down
3 changes: 2 additions & 1 deletion src/lib-jj-helpers/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ pub use bookmarks::{
TRUNK_BOOKMARKS,
};
pub use workspace::{
inject_git_dir_for_gh, is_inside_workspace, list_workspace_roots, resolve_git_dir,
inject_git_dir_for_gh, inject_git_dir_for_gh_with, is_inside_workspace, list_workspace_roots,
resolve_git_dir,
resolve_main_workspace_root, GitDirResolution,
};
25 changes: 21 additions & 4 deletions src/lib-jj-helpers/src/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,21 @@ fn strip_windows_verbatim_prefix(p: &std::path::Path) -> std::path::PathBuf {
/// - 導出失敗 → warning ログのみで続行 (fail-soft — colocated では本機能自体が
/// 不要であり、失敗時の挙動は従来と同じ「gh が repo 解決に失敗」に留まるため)
pub fn inject_git_dir_for_gh(log_info: fn(&str)) {
inject_git_dir_for_gh_with(log_info, true);
}

/// [`inject_git_dir_for_gh`] と同じ注入を行い、**導出失敗の警告を出すかを選べる** 版
/// (順位 467 F-2)。
///
/// `warn_when_unresolved = false` にするのは、呼び出し側が `gh --repo <owner/name>` の
/// ように**リポジトリを明示していて、git からの解決を必要としない**場合。導出失敗の
/// 警告は「gh の repo 解決が失敗しうる」という予測なので、その予測が成り立たない
/// 呼び出しで出すと毎回のノイズにしかならない (夜間 workflow は jj リポジトリ外で
/// 走るため、実際に毎晩出ていた)。
///
/// 注入そのものは条件に関わらず行う。`--repo` を渡していても `git ls-remote origin`
/// のように git 側がリポジトリを要る経路は残るため、注入まで止めると別の経路を壊す。
pub fn inject_git_dir_for_gh_with(log_info: fn(&str), warn_when_unresolved: bool) {
if std::env::var_os("GIT_DIR").is_some() {
return;
}
Expand All @@ -154,10 +169,12 @@ pub fn inject_git_dir_for_gh(log_info: fn(&str)) {
));
}
GitDirResolution::Unresolved(reason) => {
log_info(&format!(
"[env] GIT_DIR 導出失敗 (gh の repo 解決は失敗する可能性): {}",
reason
));
if warn_when_unresolved {
log_info(&format!(
"[env] GIT_DIR 導出失敗 (gh の repo 解決は失敗する可能性): {}",
reason
));
}
}
}
}
Expand Down
143 changes: 132 additions & 11 deletions src/lib-ledger/src/summary_gate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,12 @@ fn summary_table_header(lines: &[&str], index: usize) -> Result<Option<usize>, S
};
if index + 1 >= lines.len() || !crate::is_separator_row(lines[index + 1]) {
return Err(format!(
"{} 行目: 順位 table のヘッダ行の直後に区切り行がありません",
index + 1
"{}: 順位 table のヘッダ行の直後に区切り行がありません",
SourceLine {
number: index + 1,
text: lines[index],
}
.describe()
));
}
Ok(Some(rank_column))
Expand Down Expand Up @@ -135,29 +139,74 @@ fn consume_rows(
entries.push(take_entry(
&crate::split_cells(line),
rank_column,
index + 1,
SourceLine {
number: index + 1,
text: line,
},
)?);
index += 1;
}
Ok(index)
}

/// エラーメッセージに載せる「どの行か」の情報 (順位 467 D-2)。
///
/// 行番号だけだと、summary が数千行あるうえ table が複数あるため、報告を受けた側は
/// 結局ファイルを開いて数えることになる。**行の中身も一緒に出す**ことで、ログだけで
/// 「何が読めなかったか」まで分かるようにする。
pub(crate) struct SourceLine<'a> {
pub(crate) number: usize,
pub(crate) text: &'a str,
}

impl SourceLine<'_> {
/// エラー本文の先頭に付ける位置 + 中身の表示。
fn describe(&self) -> String {
format!("{} 行目 ({})", self.number, clip_for_message(self.text))
}
}

/// エラーメッセージに載せる文字列を上限で切り、切ったことを明示する。
///
/// 黙って切ると「これが全部」と誤読されるので必ず注記を付ける。
///
/// **メッセージに載る文字列はすべて本関数を通すこと** (PR #437 CodeRabbit Minor):
/// 行全体だけを切って個々のセルを素通しにすると、長いセルが 1 つあるだけで上限が
/// 意味を失う。実際、当初は順位セル (`{raw:?}`) が本関数を通っておらず、
/// 「長い行は切る」という保証がその経路で崩れていた。
fn clip_for_message(text: &str) -> String {
const MAX_CHARS: usize = 120;
let trimmed = text.trim();
let shown: String = trimmed.chars().take(MAX_CHARS).collect();
if trimmed.chars().count() > MAX_CHARS {
format!("{shown} …(以下略)")
} else {
shown
}
}

/// 順位 table の 1 行を [`SummaryEntry`] にする。
///
/// 順位以外の列は**表示用**であり、欠けても停止しない (空文字で埋める)。順位だけは
/// 判定に使うため、読めなければエラーにする。
fn take_entry(
cells: &[String],
rank_column: usize,
line_number: usize,
line: SourceLine<'_>,
) -> Result<SummaryEntry, String> {
let raw = cells
.get(rank_column)
.ok_or_else(|| format!("{line_number} 行目: 順位 table の行に順位列がありません"))?;
let rank = raw
.trim()
.parse::<u32>()
.map_err(|_| format!("{line_number} 行目: 順位を整数として読めません: {raw:?}"))?;
let raw = cells.get(rank_column).ok_or_else(|| {
format!(
"{}: 順位 table の行に順位列がありません",
line.describe()
)
})?;
let rank = raw.trim().parse::<u32>().map_err(|_| {
format!(
"{}: 順位を整数として読めません: {:?}",
line.describe(),
clip_for_message(raw),
)
})?;
let cell = |offset: usize| {
cells
.get(rank_column + offset)
Expand Down Expand Up @@ -334,6 +383,78 @@ mod tests {
assert!(parse_summary_ranks(markdown).is_err());
}

/// 順位 467 D-2: parse エラーの診断。
///
/// summary は数千行あり table も複数あるため、行番号だけでは報告を受けた側が
/// ファイルを開いて数える羽目になる。**行の中身まで**メッセージに載せる。
mod rank467_parse_error_diagnostics {
use super::*;

fn error_of(markdown: &str) -> String {
parse_summary_ranks(markdown).expect_err("parse should fail")
}

/// 読めなかった行そのものがメッセージに出ること (bad → 診断可能)。
#[test]
fn a_non_numeric_rank_error_names_the_line_and_shows_it() {
let message = error_of(&summary(
"| 二百三 | T2 | テスト追加 | todo3.md | XS | なし |",
));
assert!(message.contains("行目"), "{message}");
assert!(
message.contains("二百三"),
"読めなかった行の中身がメッセージに無い: {message}",
);
}

/// 区切り行欠落もヘッダ行の中身を出す (どの table か特定できるように)。
#[test]
fn a_missing_separator_error_shows_the_header_line() {
let message =
error_of("| 順位 | Tier | タスク | ファイル |\n| 203 | T2 | x | todo3.md |\n");
assert!(message.contains("行目"), "{message}");
assert!(
message.contains("順位") && message.contains("Tier"),
"ヘッダ行の中身がメッセージに無い: {message}",
);
}

/// 順位セル自体が長い場合も切ること (PR #437 CodeRabbit Minor)。
///
/// 当初のテストは長い文字列を**タイトル列**に置いていたため、順位セルを
/// そのまま載せる経路 (`{raw:?}`) を一度も通っておらず、上限の保証が
/// 崩れていることに気づけなかった。**どのセル経由でも切れること**を固定する。
#[test]
fn a_very_long_rank_cell_is_truncated_too() {
let long_rank = "壱".repeat(400);
let message = error_of(&summary(&format!(
"| {long_rank} | T2 | テスト追加 | todo3.md | XS | なし |"
)));
assert!(message.contains("以下略"), "{message}");
assert!(
message.chars().count() < long_rank.chars().count(),
"順位セルがそのまま出ている: {} 文字",
message.chars().count(),
);
}

/// 長い行はログを潰さないよう切るが、**切ったことを明示する**。
/// 黙って切ると「この行が全部」と誤読される。
#[test]
fn a_very_long_line_is_truncated_and_says_so() {
let long_title = "あ".repeat(400);
let message = error_of(&summary(&format!(
"| 二百三 | T2 | {long_title} | todo3.md | XS | なし |"
)));
assert!(message.contains("以下略"), "{message}");
assert!(
message.chars().count() < long_title.chars().count(),
"長い行がそのまま出ている: {} 文字",
message.chars().count(),
);
}
}

/// 順位 220 以降は 2 つ目のファイルにある。呼び手が和集合を取ることを前提に、
/// 単体では「自分のファイルに載っている分だけ」を返す。
#[test]
Expand Down
Loading