@@ -223,7 +223,7 @@ function request(method, endpoint, body = null, tokenOverride = null, opts = {})
223223// raw comma/space-separated permission list directly (passed through
224224// as-is, validated server-side against the CLI-eligible scope set).
225225const SCOPE_PRESETS = {
226- "read-only" : "read:projects read:tasks read:builds read:assets" ,
226+ "read-only" : "read:projects read:tasks read:builds read:assets read:changes " ,
227227 full : "" // empty scope param -> server defaults to the full CLI set
228228} ;
229229
@@ -615,6 +615,9 @@ async function buildsShow(args) {
615615 console . log ( ` Overall: ${ res . overall_status || "-" } ` ) ;
616616 console . log ( ` Job: ${ res . ci_job_name || "-" } ` ) ;
617617 console . log ( ` Commit: ${ res . commit_hash || "-" } ` ) ;
618+ if ( res . commit_hash ) {
619+ console . log ( ` Change: ${ colors . muted } butter changes show ${ res . commit_hash } --project ${ projectId } ${ colors . reset } ` ) ;
620+ }
618621 console . log ( ` Type: ${ res . build_type || "-" } ${ res . target_type ? ` / ${ res . target_type } ` : "" } ` ) ;
619622 console . log ( ` Duration: ${ res . duration != null ? `${ res . duration } s` : "-" } ` ) ;
620623 console . log ( ` Created: ${ res . created_at || "-" } ` ) ;
@@ -781,6 +784,201 @@ async function buildsInvestigate(args) {
781784 }
782785}
783786
787+ // -- Changes: commits and changelists across the git, Perforce and Lore rails --
788+
789+ // Friendly --source names mapped onto the API's source_type. Lore and
790+ // Perforce share source_type "Changelist", so those two are split by
791+ // identifier on this side: Lore changelists are namespaced "lore-<n>",
792+ // Perforce ones are bare numbers. The raw API values are accepted as-is.
793+ const CHANGE_SOURCES = {
794+ git : { sourceType : "GitCommit" } ,
795+ perforce : { sourceType : "Changelist" , keep : ( c ) => c . source_type === "Changelist" && ! String ( c . identifier ) . startsWith ( "lore-" ) } ,
796+ lore : { sourceType : "Changelist" , keep : ( c ) => c . source_type === "Changelist" && String ( c . identifier ) . startsWith ( "lore-" ) } ,
797+ GitCommit : { sourceType : "GitCommit" } ,
798+ AssetCommit : { sourceType : "AssetCommit" } ,
799+ Changelist : { sourceType : "Changelist" }
800+ } ;
801+
802+ function changeSourceLabel ( c ) {
803+ if ( c . source_type === "GitCommit" ) return "git" ;
804+ if ( c . source_type === "AssetCommit" ) return "asset" ;
805+ if ( c . source_type === "Changelist" ) return String ( c . identifier ) . startsWith ( "lore-" ) ? "lore" : "perforce" ;
806+ return c . source_type || "-" ;
807+ }
808+
809+ // A BuildRun's commit_hash is not always a Change identifier verbatim: a
810+ // Perforce build stores "p4-<n>" while its Change stores the bare "<n>".
811+ // The identifier filter is exact and never validated, so passing "p4-207"
812+ // through unchanged returns an empty list rather than an error.
813+ function changeLookupFor ( ref ) {
814+ const m = / ^ p 4 - ( \d + ) $ / . exec ( ref ) ;
815+ if ( m ) return { identifier : m [ 1 ] , sourceType : "Changelist" } ;
816+ return { identifier : ref , sourceType : null } ;
817+ }
818+
819+ function reportChangesError ( action , err ) {
820+ console . error ( `${ colors . err } ✗ Failed to ${ action } : ${ err . message } ${ colors . reset } ` ) ;
821+ const body = err . response ;
822+ if ( err . statusCode === 403 && body && body . required_scope === "read:changes" ) {
823+ console . error (
824+ `${ colors . muted } This token does not carry read:changes. Tokens issued before the changes commands shipped never got it: run \`butter auth login\` again to reissue.${ colors . reset } `
825+ ) ;
826+ }
827+ process . exit ( 1 ) ;
828+ }
829+
830+ async function changesList ( args ) {
831+ const projectId = args . project || loadConfig ( ) . defaultProject ;
832+ if ( ! projectId ) {
833+ console . error ( `${ colors . err } ✗ Missing project ID. Use --project <id>${ colors . reset } ` ) ;
834+ process . exit ( 1 ) ;
835+ }
836+
837+ const query = new URLSearchParams ( ) ;
838+ let keep = null ;
839+ if ( args . source ) {
840+ const source = CHANGE_SOURCES [ args . source ] ;
841+ if ( ! source ) {
842+ console . error ( `${ colors . err } ✗ Unknown --source "${ args . source } ". Use lore, git, or perforce.${ colors . reset } ` ) ;
843+ process . exit ( 1 ) ;
844+ }
845+ query . set ( "source_type" , source . sourceType ) ;
846+ keep = source . keep || null ;
847+ }
848+ if ( args . identifier ) {
849+ const lookup = changeLookupFor ( String ( args . identifier ) ) ;
850+ query . set ( "identifier" , lookup . identifier ) ;
851+ if ( lookup . sourceType && ! args . source ) query . set ( "source_type" , lookup . sourceType ) ;
852+ }
853+ if ( args . since ) query . set ( "updated_since" , args . since ) ;
854+ if ( args . orphaned ) query . set ( "orphaned" , "true" ) ;
855+ if ( args . limit ) query . set ( "limit" , args . limit ) ;
856+ // --after, not --cursor: `--cursor` is already the boolean client flag of
857+ // `butter mcp install`, so it can never carry a value.
858+ if ( typeof args . after === "string" ) query . set ( "cursor" , args . after ) ;
859+
860+ try {
861+ const res = await request ( "GET" , `/api/v1/projects/${ projectId } /changes?${ query . toString ( ) } ` ) ;
862+ const changes = keep ? res . changes . filter ( keep ) : res . changes ;
863+ if ( args . json ) {
864+ // The whole envelope, not just the array: without pagination a script
865+ // has no next_cursor to fetch the page after this one.
866+ console . log ( JSON . stringify ( { changes, pagination : res . pagination } , null , 2 ) ) ;
867+ return ;
868+ }
869+ console . log ( `\n${ colors . bold } CHANGES for Project #${ projectId } (${ changes . length } )${ colors . reset } ` ) ;
870+ console . log ( "----------------------------------------------------------------------" ) ;
871+ changes . forEach ( ( c ) => {
872+ const summary = String ( c . description || "" ) . split ( "\n" ) [ 0 ] ;
873+ const orphaned = c . orphaned_at ? ` ${ colors . warn } (orphaned)${ colors . reset } ` : "" ;
874+ console . log (
875+ ` Change #${ c . id . toString ( ) . padEnd ( 6 ) } ${ colors . accent } [${ changeSourceLabel ( c ) } ]${ colors . reset } ${ c . display_identifier || c . identifier } ${ colors . muted } ${ c . author || "-" } ${ colors . reset } ${ summary } ${ orphaned } `
876+ ) ;
877+ } ) ;
878+ if ( res . pagination && res . pagination . has_more && res . pagination . next_cursor ) {
879+ console . log (
880+ `\n ${ colors . muted } More: butter changes list --project ${ projectId } --after ${ res . pagination . next_cursor } ${ colors . reset } `
881+ ) ;
882+ }
883+ console . log ( "" ) ;
884+ } catch ( err ) {
885+ reportChangesError ( "list changes" , err ) ;
886+ }
887+ }
888+
889+ async function changesShow ( args ) {
890+ const projectId = args . project || loadConfig ( ) . defaultProject ;
891+ const ref = args . _ [ 2 ] !== undefined ? String ( args . _ [ 2 ] ) : null ;
892+ if ( ! projectId || ! ref ) {
893+ console . error ( `${ colors . err } ✗ Usage: butter changes show <change_id|commit> --project <id>${ colors . reset } ` ) ;
894+ process . exit ( 1 ) ;
895+ }
896+
897+ try {
898+ // An all-digit ref is the change id that `changes list` prints. Anything
899+ // else is a commit reference (a git SHA, a build's p4-<n>, a lore-<n>),
900+ // resolved through the exact-match identifier filter first.
901+ let changeId = ref ;
902+ if ( ! / ^ \d + $ / . test ( ref ) ) {
903+ const lookup = changeLookupFor ( ref ) ;
904+ const query = new URLSearchParams ( { identifier : lookup . identifier , orphaned : "true" } ) ;
905+ if ( lookup . sourceType ) query . set ( "source_type" , lookup . sourceType ) ;
906+ const found = await request ( "GET" , `/api/v1/projects/${ projectId } /changes?${ query . toString ( ) } ` ) ;
907+ if ( ! found . changes || found . changes . length === 0 ) {
908+ console . error ( `${ colors . err } ✗ No change with identifier "${ lookup . identifier } " in project ${ projectId } .${ colors . reset } ` ) ;
909+ console . error (
910+ `${ colors . muted } The match is exact: use the full commit SHA, a Perforce build's p4-<n>, or lore-<n>. A bare Perforce changelist number is read as a change id; use: butter changes list --project ${ projectId } --identifier <n>${ colors . reset } `
911+ ) ;
912+ process . exit ( 1 ) ;
913+ }
914+ changeId = found . changes [ 0 ] . id ;
915+ }
916+
917+ const res = await request ( "GET" , `/api/v1/projects/${ projectId } /changes/${ changeId } ` ) ;
918+ if ( args . json ) {
919+ console . log ( JSON . stringify ( res , null , 2 ) ) ;
920+ return ;
921+ }
922+
923+ console . log ( `\n${ colors . bold } CHANGE ${ res . id } ${ colors . reset } ` ) ;
924+ console . log ( "----------------------------------------------------------------------" ) ;
925+ console . log ( ` Identifier: ${ colors . accent } ${ res . identifier || "-" } ${ colors . reset } ` ) ;
926+ console . log ( ` Source: ${ changeSourceLabel ( res ) } (${ res . source_type || "-" } )` ) ;
927+ console . log ( ` Author: ${ res . author || "-" } ` ) ;
928+ console . log ( ` Timestamp: ${ res . timestamp || "-" } ` ) ;
929+ if ( res . orphaned_at ) {
930+ console . log ( ` Orphaned: ${ colors . warn } ${ res . orphaned_at } ${ colors . reset } ` ) ;
931+ }
932+ if ( res . files_available === false ) {
933+ console . log ( ` Files: ${ colors . muted } not available for this change${ colors . reset } ` ) ;
934+ } else {
935+ console . log ( ` Files: ${ res . file_count != null ? res . file_count : Array . isArray ( res . files ) ? res . files . length : "-" } ` ) ;
936+ }
937+ const a = res . approvals ;
938+ if ( a ) {
939+ console . log ( ` Approvals: ${ a . approved } approved, ${ a . pending } pending, ${ a . denied } denied, ${ a . ignored } ignored` ) ;
940+ }
941+ const tasks = Array . isArray ( res . task_ids ) && res . task_ids . length ? res . task_ids . map ( ( id ) => `#${ id } ` ) . join ( ", " ) : "none" ;
942+ console . log ( ` Tasks: ${ tasks } ` ) ;
943+
944+ const b = res . latest_build ;
945+ if ( b ) {
946+ const statusColor = b . status === "completed" ? colors . ok : b . status === "failed" ? colors . err : colors . warn ;
947+ console . log ( ` Latest build: ${ b . id } ${ statusColor } ${ b . status } ${ colors . reset } ${ b . ci_job_name ? ` ${ b . ci_job_name } ` : "" } ` ) ;
948+ } else {
949+ console . log ( ` Latest build: ${ colors . muted } none${ colors . reset } ` ) ;
950+ }
951+
952+ if ( res . description ) {
953+ console . log ( `\n${ colors . bold } Description:${ colors . reset } ` ) ;
954+ String ( res . description )
955+ . trimEnd ( )
956+ . split ( "\n" )
957+ . forEach ( ( line ) => console . log ( ` ${ line } ` ) ) ;
958+ }
959+
960+ if ( Array . isArray ( res . build_runs ) && res . build_runs . length ) {
961+ console . log ( `\n${ colors . bold } Builds (${ res . build_runs . length } ):${ colors . reset } ` ) ;
962+ res . build_runs . forEach ( ( br ) => {
963+ const c = br . status === "completed" ? colors . ok : br . status === "failed" ? colors . err : colors . warn ;
964+ console . log ( ` ${ br . id } ${ c } [${ br . status } ]${ colors . reset } ${ br . ci_job_name || "" } ${ colors . muted } ${ br . commit_hash || "" } ${ colors . reset } ` ) ;
965+ } ) ;
966+ }
967+
968+ if ( Array . isArray ( res . files ) && res . files . length ) {
969+ const shown = res . files . slice ( 0 , 20 ) ;
970+ console . log ( `\n${ colors . bold } Files (${ res . files . length } ):${ colors . reset } ` ) ;
971+ shown . forEach ( ( f ) => console . log ( ` ${ colors . muted } ${ ( f . action || "" ) . padEnd ( 8 ) } ${ colors . reset } ${ f . path || "-" } ` ) ) ;
972+ if ( res . files . length > shown . length ) {
973+ console . log ( ` ${ colors . muted } ... and ${ res . files . length - shown . length } more (use --json for all)${ colors . reset } ` ) ;
974+ }
975+ }
976+ console . log ( "" ) ;
977+ } catch ( err ) {
978+ reportChangesError ( "show change" , err ) ;
979+ }
980+ }
981+
784982async function assetsList ( args ) {
785983 const projectId = args . project || loadConfig ( ) . defaultProject ;
786984 if ( ! projectId ) {
@@ -1172,7 +1370,8 @@ const BOOLEAN_FLAGS = new Set([
11721370 "dry-run" ,
11731371 "opencode" ,
11741372 "claude" ,
1175- "cursor"
1373+ "cursor" ,
1374+ "orphaned"
11761375] ) ;
11771376
11781377// `--key=value` is always explicit, so it is honored for any key including
@@ -1220,6 +1419,11 @@ ${colors.bold}COMMANDS:${colors.reset}
12201419 Inspect CI/CD builds and AI failure investigations.
12211420 ${ colors . bold } investigate${ colors . reset } STARTS a paid AI analysis;
12221421 ${ colors . bold } investigation${ colors . reset } reads the result back for free.
1422+ ${ colors . accent } changes${ colors . reset } list | show <id|commit> Commits and changelists (git, Perforce, Lore).
1423+ ${ colors . bold } show${ colors . reset } takes a change id, or a build's commit
1424+ (SHA, p4-<n>, lore-<n>) to find the change it belongs to.
1425+ list filters: --source lore|git|perforce, --since <date>,
1426+ --identifier <x>, --orphaned, --limit <n>, --after <cursor>
12231427 ${ colors . accent } assets${ colors . reset } list | approve | deny Review and approve textures, models, and audio
12241428 ${ colors . bold } show${ colors . reset } is coming soon
12251429 ${ colors . accent } mcp${ colors . reset } install Connect OpenCode, Claude Desktop, and Cursor
@@ -1240,6 +1444,8 @@ ${colors.bold}AUTH OPTIONS:${colors.reset}
12401444
12411445Tokens expire 90 days after issue; ${ colors . bold } butter auth whoami${ colors . reset } shows the
12421446remaining lifetime and ${ colors . bold } butter auth login${ colors . reset } again renews it.
1447+ The ${ colors . bold } changes${ colors . reset } commands need the read:changes scope; a token issued before
1448+ they shipped does not have it, so run ${ colors . bold } butter auth login${ colors . reset } again once.
12431449` ) ;
12441450}
12451451
@@ -1281,6 +1487,11 @@ async function main() {
12811487 if ( subcmd === "investigate" ) return buildsInvestigate ( args ) ;
12821488 if ( subcmd === "investigation" ) return buildsInvestigation ( args ) ;
12831489 break ;
1490+ case "changes" :
1491+ case "change" :
1492+ if ( subcmd === "list" || ! subcmd ) return changesList ( args ) ;
1493+ if ( subcmd === "show" ) return changesShow ( args ) ;
1494+ break ;
12841495 case "assets" :
12851496 case "asset" :
12861497 if ( subcmd === "list" || ! subcmd ) return assetsList ( args ) ;
0 commit comments