@@ -1161,6 +1161,201 @@ defmodule LinearCli.CLI.Commands do
11611161
11621162 defp truncate_message ( msg , _max ) , do: msg
11631163
1164+ @ doc """
1165+ Removes a relationship from `ISSUE` to one or more `RELATED_ISSUE`s.
1166+
1167+ The first element of `unknown` is the subject issue; the remaining elements
1168+ are the related issues. `--type` controls which stored relation to match:
1169+
1170+ * `blocks` — removes the relation where subject blocks each related issue
1171+ * `blocked-by` — removes the relation where each related issue blocks subject
1172+ * `related` — removes the related relation
1173+ * `duplicate` — removes the duplicate relation
1174+
1175+ Removing an absent relation is a per-target no-op (not an error). If more
1176+ than one stored relation matches for a target, that target fails and every
1177+ matching relation ID is listed — nothing is deleted arbitrarily.
1178+
1179+ Each target is processed independently; partial failures do not roll back
1180+ successful deletions. All results are printed before returning; a non-zero
1181+ exit identifies the overall failure count if any target failed.
1182+ """
1183+ @ spec issue_relation_remove ( Optimus.ParseResult . t ( ) ) :: :ok | { :error , term ( ) }
1184+ def issue_relation_remove ( % { unknown: [ ] } ) ,
1185+ do: { :error , { :smells_bad , "ISSUE and at least one RELATED_ISSUE are required" } }
1186+
1187+ def issue_relation_remove ( % { unknown: [ _subject ] } ) ,
1188+ do: { :error , { :smells_bad , "At least one RELATED_ISSUE is required" } }
1189+
1190+ def issue_relation_remove ( % { unknown: [ subject_id | related_ids ] , options: options } ) do
1191+ expanded_subject = IssueHelpers . expand_issue_id ( subject_id )
1192+ user_type = options . type
1193+
1194+ with { :ok , all_relations } <- Linear . issue_relations ( expanded_subject ) do
1195+ results =
1196+ Enum . map ( related_ids , fn related_id ->
1197+ expanded_related = IssueHelpers . expand_issue_id ( related_id )
1198+ remove_single_relation ( expanded_subject , expanded_related , user_type , all_relations )
1199+ end )
1200+
1201+ print_relation_remove_results ( results , options . output )
1202+
1203+ failed_count =
1204+ Enum . count ( results , fn r ->
1205+ match? ( { :failed , _ , _ } , r ) or match? ( { :ambiguous , _ , _ } , r ) or
1206+ match? ( { :self_link , _ } , r )
1207+ end )
1208+
1209+ if failed_count > 0 do
1210+ { :error , { :smells_bad , "#{ failed_count } relation(s) failed to be removed" } }
1211+ else
1212+ :ok
1213+ end
1214+ end
1215+ end
1216+
1217+ defp remove_single_relation ( subject_id , related_id , _user_type , _relations )
1218+ when subject_id == related_id do
1219+ { :self_link , subject_id }
1220+ end
1221+
1222+ defp remove_single_relation ( subject_id , related_id , user_type , all_relations ) do
1223+ subject_id
1224+ |> find_matching_relations ( related_id , user_type , all_relations )
1225+ |> do_remove ( related_id )
1226+ end
1227+
1228+ defp do_remove ( [ ] , related_id ) , do: { :absent , related_id }
1229+
1230+ defp do_remove ( [ relation ] , related_id ) do
1231+ case Linear . delete_issue_relation ( relation ) do
1232+ :ok -> { :removed , related_id , relation }
1233+ { :error , reason } -> { :failed , related_id , reason }
1234+ end
1235+ end
1236+
1237+ defp do_remove ( relations , related_id ) do
1238+ { :ambiguous , related_id , Enum . map ( relations , & & 1 . id ) }
1239+ end
1240+
1241+ # Finds stored relations that match the user-facing type and the given endpoint pair.
1242+ # For `blocked-by`: the stored relation is `blocks` in the inbound direction, meaning
1243+ # the related_id issue is the source (`issue`) and subject is the destination (`related_issue`).
1244+ # For all other types: the stored relation is outbound with the subject as source.
1245+ defp find_matching_relations ( _subject_id , related_id , "blocked-by" , all_relations ) do
1246+ Enum . filter ( all_relations , fn rel ->
1247+ rel . direction == :inbound and
1248+ rel . type == "blocks" and
1249+ rel . issue != nil and
1250+ rel . issue . identifier == related_id
1251+ end )
1252+ end
1253+
1254+ defp find_matching_relations ( _subject_id , related_id , user_type , all_relations ) do
1255+ Enum . filter ( all_relations , fn rel ->
1256+ rel . direction == :outbound and
1257+ rel . type == user_type and
1258+ rel . related_issue != nil and
1259+ rel . related_issue . identifier == related_id
1260+ end )
1261+ end
1262+
1263+ defp print_relation_remove_results ( results , output ) do
1264+ if output == "json" do
1265+ results
1266+ |> Enum . map ( & relation_remove_result_to_plain / 1 )
1267+ |> Jason . encode! ( pretty: true )
1268+ |> IO . puts ( )
1269+ else
1270+ Enum . each ( results , & print_relation_remove_result_text / 1 )
1271+ end
1272+ end
1273+
1274+ defp relation_remove_result_to_plain ( { :removed , related_id , relation } ) do
1275+ % {
1276+ "target" => related_id ,
1277+ "status" => "removed" ,
1278+ "relation" => Display . relation_to_plain ( relation )
1279+ }
1280+ end
1281+
1282+ defp relation_remove_result_to_plain ( { :absent , related_id } ) do
1283+ % { "target" => related_id , "status" => "absent" }
1284+ end
1285+
1286+ defp relation_remove_result_to_plain ( { :self_link , id } ) do
1287+ % {
1288+ "target" => id ,
1289+ "status" => "error" ,
1290+ "message" => "self-link: an issue cannot be related to itself"
1291+ }
1292+ end
1293+
1294+ defp relation_remove_result_to_plain ( { :ambiguous , related_id , ids } ) do
1295+ % {
1296+ "target" => related_id ,
1297+ "status" => "error" ,
1298+ "message" => "ambiguous: multiple matching relations found: #{ Enum . join ( ids , ", " ) } "
1299+ }
1300+ end
1301+
1302+ defp relation_remove_result_to_plain ( { :failed , related_id , reason } ) do
1303+ msg = reason |> relation_remove_error_message ( ) |> truncate_message ( 200 )
1304+ % { "target" => related_id , "status" => "error" , "message" => msg }
1305+ end
1306+
1307+ defp print_relation_remove_result_text ( { :removed , _related_id , relation } ) do
1308+ IO . puts ( relation_remove_removed_text ( relation ) )
1309+ end
1310+
1311+ defp print_relation_remove_result_text ( { :absent , related_id } ) do
1312+ Prompt . ok ( "#{ related_id } : relation not found (no change)" )
1313+ end
1314+
1315+ defp print_relation_remove_result_text ( { :self_link , id } ) do
1316+ IO . puts ( :stderr , "#{ id } : self-link — an issue cannot be related to itself" )
1317+ end
1318+
1319+ defp print_relation_remove_result_text ( { :ambiguous , related_id , ids } ) do
1320+ IO . puts (
1321+ :stderr ,
1322+ "#{ related_id } : ambiguous — #{ length ( ids ) } matching relations: #{ Enum . join ( ids , ", " ) } "
1323+ )
1324+ end
1325+
1326+ defp print_relation_remove_result_text ( { :failed , related_id , reason } ) do
1327+ msg = relation_remove_error_message ( reason )
1328+ IO . puts ( :stderr , "#{ related_id } : #{ msg } " )
1329+ end
1330+
1331+ defp relation_remove_removed_text ( % { type: "blocks" , issue: issue , related_issue: related } ) do
1332+ "#{ issue . identifier } no longer blocks #{ related . identifier } "
1333+ end
1334+
1335+ defp relation_remove_removed_text ( % { type: "related" , issue: issue , related_issue: related } ) do
1336+ "#{ issue . identifier } is no longer related to #{ related . identifier } "
1337+ end
1338+
1339+ defp relation_remove_removed_text ( % { type: "duplicate" , issue: issue , related_issue: related } ) do
1340+ "#{ issue . identifier } is no longer a duplicate of #{ related . identifier } "
1341+ end
1342+
1343+ defp relation_remove_removed_text ( % { type: type , issue: issue , related_issue: related } ) do
1344+ "#{ issue . identifier } is no longer a #{ type } of #{ related . identifier } "
1345+ end
1346+
1347+ defp relation_remove_error_message ( % Ash.Error.Unknown {
1348+ errors: [ % { value: [ { :graphql_errors , [ % { "message" => msg } | _ ] } ] } | _ ]
1349+ } ) ,
1350+ do: "Linear API error: #{ msg } "
1351+
1352+ defp relation_remove_error_message ( % Ash.Error.Unknown {
1353+ errors: [ % Ash.Error.Unknown.UnknownError { error: "unknown error: :missing_api_key" } | _ ]
1354+ } ) ,
1355+ do: "LINEAR_API_KEY is not set"
1356+
1357+ defp relation_remove_error_message ( _reason ) , do: "unexpected error"
1358+
11641359 defp resolve_optional_status ( _issue , nil ) , do: { :ok , nil }
11651360
11661361 defp resolve_optional_status ( issue , name ) do
0 commit comments